diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..50ac06b --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,15 @@ +# Default accountable owner +* @altmanAI + +# Human-first decision logic, data contracts, and profiles +/engine/ @altmanAI +/dailypilot_cli.py @altmanAI + +# Tests, security, documentation, and automation +/test_*.py @altmanAI +/sample_day.json @altmanAI +/SECURITY.md @altmanAI +/README.md @altmanAI +/architecture.md @altmanAI +/api.md @altmanAI +/.github/ @altmanAI diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..86d6ea2 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,49 @@ +name: DailyPilot Engine CI + +on: + pull_request: + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: dailypilot-ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: Compile, test, and smoke test + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Check out repository without external actions + shell: bash + run: | + set -euo pipefail + git init . + git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" + git fetch --no-tags --depth=1 origin "${GITHUB_REF}" + git checkout --detach FETCH_HEAD + + - name: Report Python runtime + run: python3 --version + + - name: Create isolated test environment + run: python3 -m venv .venv + + - name: Install test runner + run: .venv/bin/python -m pip install --disable-pip-version-check "pytest>=8,<10" + + - name: Compile Python sources + run: .venv/bin/python -m compileall -q engine dailypilot_cli.py test_*.py + + - name: Run tests + run: .venv/bin/python -m pytest -q + + - name: Run documented CLI smoke test + run: .venv/bin/python dailypilot_cli.py sample_day.json --profile worker_double_shift diff --git a/README.md b/README.md index 4df383b..d1c66fc 100644 --- a/README.md +++ b/README.md @@ -1,49 +1,199 @@ -# DailyPilot-Engine +# DailyPilot Engine -DailyPilot-Engine is the human-centered prioritization core of **DailyPilot by AltmanAI (Altman Family Group, LLC)**. - -It transforms messy task lists and shifting schedules into a focused, realistic daily plan that feels calm instead of overwhelming — with explainable, stress-aware scoring logic, not a black box. +> A transparent reference engine for turning a task list into a bounded, reviewable daily plan. **Humanity leads. Intelligence follows.** ---- +DailyPilot Engine is a small Python reference implementation maintained by AltmanAI, an Altman Family Group LLC initiative. It demonstrates explainable task scoring and effort-budgeted plan construction without hiding the decision logic behind a black box. + +## Status + +- **Lifecycle:** Reference implementation +- **Language:** Python 3.10+ +- **Core runtime dependencies:** Python standard library only +- **Production integration:** Separate product integration work may be in progress; this repository alone is not a production service +- **Decision authority:** Human review required + +This engine is not medical, mental-health, legal, financial, employment, education, or safety-critical decision software. It does not understand the full context of a person's obligations, risks, accessibility needs, relationships, or wellbeing. + +## What it does + +1. Accepts tasks expressed through the `Task` data model. +2. Normalizes importance, urgency, effort, and deadline proximity. +3. Applies a visible stress-impact penalty. +4. Produces a score and factor breakdown for every task. +5. Builds a plan constrained by an effort budget and maximum focus-task count. +6. Parks work that does not fit within the configured budget. +7. Writes a structured local run log when the CLI is used. + +## What it does not do + +- autonomously decide what a person must do; +- guarantee that a generated plan is safe, complete, healthy, fair, or optimal; +- resolve calendar conflicts, task dependencies, travel time, emergencies, or hidden obligations; +- infer a person's mental state or diagnose stress; +- replace professional or domain-qualified judgment; +- execute external actions or access accounts, calendars, messages, or private services; +- learn from user data or send task data to an external model provider. + +The current scoring method is an inspectable heuristic. A high-stress task may be deprioritized by the configured penalty even when outside context makes it essential. Users and integrators must review results and preserve a direct correction or override path. + +## Repository layout + +```text +. +├── engine/ +│ ├── __init__.py +│ ├── core/ +│ │ ├── __init__.py +│ │ ├── models.py # Task, profile, score, plan, and run-log models +│ │ ├── scoring.py # Transparent scoring heuristic and breakdown +│ │ └── selectors.py # Effort-budgeted plan construction +│ └── profiles/ +│ ├── student.json +│ ├── worker_double_shift.json +│ └── founder.json +├── dailypilot_cli.py # Local command-line harness +├── sample_day.json # Example task input +├── test_scoring.py # Scoring behavior tests +├── test_selectors.py # Plan-selection behavior tests +└── test_profiles.py # Profile integrity tests +``` -## What this repo does +The package layout prevents collisions with Python standard-library modules and provides one consistent import path for code, tests, CLI use, and documentation. -- Takes a raw set of tasks/schedule inputs (see `sample_day.json`, `student.json`, `worker_double_shift.json`, `founder.json` for example profiles) -- Scores and selects a realistic daily plan using `scoring.py` and `selectors.py` -- Exposes a data contract via `models.py` -- Ships a CLI entry point: `dailypilot_cli.py` +## Quick start -## Setup +Clone the repository and enter its directory, then run: ```bash -pip install -r requirements.txt +python dailypilot_cli.py sample_day.json --profile worker_double_shift ``` -## Run the CLI +The CLI prints the plan and writes a local JSON record under `logs/`. The `logs/` directory should not be committed. -```bash -python dailypilot_cli.py --input sample_day.json +### Input format + +The task file must be a JSON array. Each task requires `id` and `title`; other supported fields include: + +```json +{ + "id": "finish_report", + "title": "Finish client report", + "description": "Due soon and important for income.", + "importance": 5, + "urgency": 5, + "effort_estimate": 2.0, + "stress_impact": "MEDIUM", + "due_date": "2026-07-15", + "category": "work" +} +``` + +`due_date`, when supplied through JSON, must use `YYYY-MM-DD`. + +## Python usage + +```python +from engine.core import ProfileConfig, Task, build_daily_plan, score_tasks + +profile = ProfileConfig(name="example", daily_effort_budget_hours=3.0) +tasks = [Task(id="prepare_demo", title="Prepare product demo", importance=5)] + +scored = score_tasks(tasks, profile) +plan = build_daily_plan(scored, profile) ``` ## Run tests +Install the test runner in an isolated environment: + ```bash -python -m pytest test_scoring.py test_selectors.py test_profiles.py +python -m venv .venv +source .venv/bin/activate +python -m pip install --upgrade pip +python -m pip install "pytest>=8,<10" +python -m pytest -q ``` -## Docs +On Windows PowerShell, activate with: + +```powershell +.venv\Scripts\Activate.ps1 +``` -- `overview.md` — product overview and scoring philosophy -- `architecture.md` — system architecture -- `api.md` — API contract for app integration -- `IOS_BRIDGE.md` — notes on connecting this engine to the DailyPilot iOS app (moved here from the old root README) +The GitHub Actions workflow runs compilation, tests, and a CLI smoke test across supported Python versions. -## Status +## Scoring model + +Default weights: + +| Factor | Weight | Interpretation | +|---|---:|---| +| Importance | 0.40 | User-supplied significance, normalized from 1–5 | +| Urgency | 0.30 | User-supplied time pressure, normalized from 1–5 | +| Effort | 0.20 | Shorter estimated tasks receive a larger contribution | +| Deadline | 0.10 | Due and near-due tasks receive a larger contribution | + +The profile may override these weights. A stress-impact penalty is then subtracted: + +| Stress impact | Penalty | +|---|---:| +| LOW | 0.00 | +| MEDIUM | 0.10 | +| HIGH | 0.25 | + +Every result includes the normalized factors, base score, and penalty. This supports inspection but does not prove that the weights are universally appropriate. + +## Human-first integration requirements + +Any product integration should provide: + +- clear disclosure that the plan is generated assistance; +- understandable factor explanations; +- direct editing, correction, override, and dismissal controls; +- no coercive language or false certainty; +- no high-stakes use without qualified review and additional safeguards; +- data minimization and explicit privacy controls; +- monitoring for harmful or systematically poor recommendations; +- a documented rollback or disable path; +- an AI System Card and release record for material deployments. + +Review the organization-wide governance and release controls in [`altmanAI/.github`](https://github.com/altmanAI/.github). + +## Documentation + +- [`overview.md`](overview.md) — product and scoring overview +- [`architecture.md`](architecture.md) — current architecture and boundaries +- [`api.md`](api.md) — Python reference usage +- [`IOS_BRIDGE.md`](IOS_BRIDGE.md) — integration planning notes; not a production-readiness claim +- [`SECURITY.md`](SECURITY.md) — private vulnerability reporting + +## Security and privacy + +Do not submit real private task lists, credentials, health details, financial records, customer data, or confidential business information to public issues or pull requests. + +Report security-sensitive findings privately as described in [`SECURITY.md`](SECURITY.md). + +## Contributing + +Focused contributions are welcome. Every material pull request should include: + +- a linked objective or issue; +- tests or reproducible validation; +- capability and limitation updates; +- security, privacy, accessibility, and human-impact review; +- rollback considerations; +- disclosure of material AI assistance. + +## License -This is the reference implementation of the DailyPilot scoring engine — real, working Python logic with tests, not a concept doc. iOS/production integration is in progress; see `IOS_BRIDGE.md` for the current plan. +The repository license controls use and redistribution. Public visibility and reference status do not imply endorsement, certification, fitness for a particular purpose, or authorization for high-stakes deployment. -## Part of the AltmanAI ecosystem +## P.A.I.H.I. -DailyPilot-Engine is one product surface in the broader AltmanAI system. See [altmanAI/.github](https://github.com/altmanAI/.github) for the organization-level overview, and [altmanAI/altmanai-master-ledger](https://github.com/altmanAI/altmanai-master-ledger) for how releases here connect to the public Proof-of-AI-Human-Impact (PAIHI) record. +- **Proof:** Tests and inspectable score breakdowns support bounded behavior claims. +- **Alignment:** The engine assists human prioritization without claiming human authority. +- **Integrity:** Limitations and non-goals are documented explicitly. +- **Humanity:** Integrations must preserve agency, privacy, accessibility, correction, and recourse. +- **Impact:** Useful outcomes should be evaluated with versioned evidence rather than assumed from demonstrations. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..c392111 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,52 @@ +# Security Policy + +## Report privately + +Do not open a public issue for vulnerabilities, exposed secrets, privacy incidents, unsafe tool or model behavior, authentication or authorization failures, or unremediated exploit details. + +Report privately to: + +**security@altmanai.tech** + +Include the affected commit or component, reproduction steps, realistic impact, preconditions, and suggested containment when safely possible. Do not send live credentials or unnecessary personal data. + +## Supported code + +The current default branch and explicitly identified active release branches are supported. Historical commits, forks, experiments, and archived work may not receive fixes. + +This repository is a local reference engine. Its presence on GitHub does not establish that any particular version is deployed in production. + +## Security and privacy boundaries + +The current reference implementation: + +- reads local JSON files supplied by the operator; +- performs local Python calculations; +- writes local run logs; +- does not call external models, APIs, accounts, calendars, or messaging services; +- does not provide authentication, authorization, encryption, multi-user isolation, or production data controls. + +Any integration that adds network access, accounts, personal data, external AI models, tools, memory, retrieval, analytics, or cloud storage requires a separate threat model and release review. + +## Contributor requirements + +- Never commit tokens, passwords, private keys, certificates, production exports, or real private task lists. +- Treat task content, profile data, logs, and user routines as potentially sensitive. +- Validate untrusted JSON before use in a product boundary. +- Use least privilege for integrations and external services. +- Preserve human review, correction, override, and disable paths. +- Assess prompt injection, data exfiltration, tool misuse, and provider risk if an AI model or agent is added. +- Add tests for security-sensitive input and failure behavior. +- Document monitoring, containment, and rollback for material releases. + +## Safe research boundaries + +Good-faith research must avoid unauthorized access, data retention, service disruption, social engineering, persistence, destructive testing, or disclosure of private information. Stop and report if you encounter credentials, personal data, confidential information, or evidence of active compromise. + +## High-impact use warning + +Do not use this reference engine as the sole authority for medical, mental-health, legal, financial, employment, education, housing, safety, or other consequential decisions. Such uses require qualified domain review, additional safeguards, validated data, recourse, monitoring, and applicable legal or regulatory assessment. + +## Handling + +Reports are assessed based on evidence, exploitability, affected systems, user impact, and remediation complexity. No fixed response-time commitment is implied. Public disclosure should wait until remediation or a coordinated disclosure plan reduces avoidable risk. diff --git a/api.md b/api.md index 0a1e066..042d3ce 100644 --- a/api.md +++ b/api.md @@ -1,9 +1,112 @@ -# DailyPilot-Engine – API Notes +# DailyPilot Engine Python Reference -Python reference usage: +## Import the core interface ```python -from engine.core.models import Task, ProfileConfig +from engine.core import ProfileConfig, Task, build_daily_plan, score_tasks +``` + +Direct module imports are also available: + +```python +from engine.core.models import ProfileConfig, Task from engine.core.scoring import score_tasks from engine.core.selectors import build_daily_plan ``` + +This is a reference interface at version `0.1.0`, not a guarantee of long-term package compatibility. Breaking changes must be versioned and documented. + +## Minimal example + +```python +from engine.core import ProfileConfig, Task, build_daily_plan, score_tasks + +profile = ProfileConfig( + name="example", + daily_effort_budget_hours=3.0, + max_big_focus=2, +) + +tasks = [ + Task( + id="prepare_demo", + title="Prepare product demo", + importance=5, + urgency=4, + effort_estimate=2.0, + stress_impact="MEDIUM", + ), + Task( + id="organize_notes", + title="Organize notes", + importance=3, + urgency=2, + effort_estimate=0.5, + stress_impact="LOW", + ), +] + +scored = score_tasks(tasks, profile) +plan = build_daily_plan(scored, profile) + +for result in scored: + print(result.task.title, result.score, result.breakdown) + +print([task.title for task in plan.big_focus]) +print([task.title for task in plan.support_tasks]) +print([task.title for task in plan.parked_tasks]) +``` + +## Data contracts + +### `Task` + +Required: + +- `id: str` +- `title: str` + +Selected optional fields: + +- `description: str` +- `importance: int` — intended range 1–5 +- `urgency: int` — intended range 1–5 +- `effort_estimate: float` — hours +- `stress_impact: str` — `LOW`, `MEDIUM`, or `HIGH` +- `due_date: date | None` +- `time_window: str | None` +- `category: str | None` +- `meta: dict` + +The dataclass does not currently enforce every intended range. Integrators must validate untrusted input before constructing tasks. + +### `ProfileConfig` + +- `name: str` +- `daily_effort_budget_hours: float` +- `max_big_focus: int` +- `weights: dict[str, float]` +- `max_stress_load: float` + +`max_stress_load` is present in the data model but is not currently enforced by the selector. Do not represent it as an active safety constraint. + +### `ScoredTask` + +Contains the original task, final score, and factor breakdown. + +### `Plan` + +Contains focus tasks, support tasks, parked tasks, a UTC timestamp, summary, profile name, and engine version. + +## Integration requirements + +Before using this reference logic in a product: + +- validate all untrusted input; +- document code and configuration versions; +- preserve score explanations; +- provide human correction and override; +- add privacy, authentication, authorization, abuse, and incident controls appropriate to the product; +- evaluate behavior on representative and adverse scenarios; +- do not use the output as sole authority for consequential decisions; +- document monitoring and rollback. diff --git a/architecture.md b/architecture.md index 31eb25a..4491380 100644 --- a/architecture.md +++ b/architecture.md @@ -1,9 +1,89 @@ -# DailyPilot-Engine – Architecture +# DailyPilot Engine Architecture -The engine is intentionally small and composable: +## Package structure -- `engine.core.models` – data models (Task, ProfileConfig, Plan, RunLog) -- `engine.core.scoring` – scoring logic -- `engine.core.selectors` – turns scored tasks into a daily plan -- `engine.profiles` – JSON configs for different life contexts -- `integrations.cli` – command-line harness for experiments and testing +The reference engine uses an explicit Python package: + +- `engine.core.models` — task, profile, scored-task, plan, and run-log data models; +- `engine.core.scoring` — transparent factor normalization, weighting, deadline treatment, and stress penalty; +- `engine.core.selectors` — effort-budgeted plan construction; +- `engine.profiles` — version-controlled example profile JSON files; +- `dailypilot_cli.py` — local JSON input, profile loading, output display, and run logging; +- root-level test files — behavior and integrity evidence. + +The package boundary avoids collisions with Python standard-library modules and provides one import path for tests, CLI use, and integrations. + +## Data flow + +```text +Task JSON + │ + ▼ +Task data models ──► score_tasks() ──► ordered ScoredTask records + │ +Profile JSON ─────────────────────────────────┤ + ▼ + build_daily_plan() + │ + ▼ + focus / support / parked tasks + │ + ▼ + human review + local run log +``` + +## Trust boundaries + +The reference engine: + +- reads local JSON supplied by the operator; +- performs deterministic local Python calculations except for current-date and timestamp inputs; +- writes local log files; +- does not call external AI models, APIs, accounts, calendars, or messaging services; +- does not authenticate users or manage production data. + +A product integration changes these boundaries and requires a separate security, privacy, evaluation, and release review. + +## Explainability + +Each scored task retains: + +- normalized importance; +- normalized urgency; +- normalized inverse effort; +- normalized deadline proximity; +- stress penalty; +- base score; +- final score. + +This makes the heuristic inspectable. It does not guarantee that the factors or weights are appropriate for every person or context. + +## Human-control requirements + +Integrations must preserve: + +- review before consequential action; +- correction and override; +- clear status and limitation disclosure; +- no false claim of objectivity or optimality; +- no hidden external action; +- an operator-controlled disable or rollback path; +- privacy and data-minimization controls. + +## Known limitations + +The current engine does not model: + +- task dependencies; +- fixed calendar events or travel time; +- emergencies or safety-critical obligations; +- multi-day optimization; +- changing energy across a day; +- fairness across people or teams; +- accessibility accommodations; +- emotional, medical, financial, or legal consequences; +- uncertainty in user-entered importance, urgency, effort, or stress values. + +`ProfileConfig.max_stress_load` is currently recorded but not enforced by the plan selector. It must not be represented as an active constraint until implementation and tests prove that behavior. + +These are explicit engineering boundaries, not future capability claims. diff --git a/dailypilot_cli.py b/dailypilot_cli.py index ed6707b..93e4348 100644 --- a/dailypilot_cli.py +++ b/dailypilot_cli.py @@ -1,67 +1,104 @@ import argparse import json -import os -from datetime import datetime +from datetime import date, timezone +from pathlib import Path from typing import List -from engine.core.models import Task, ProfileConfig, RunLog +from engine.core.models import ProfileConfig, RunLog, Task from engine.core.scoring import score_tasks from engine.core.selectors import build_daily_plan +BASE_DIR = Path(__file__).resolve().parent +PROFILE_DIR = BASE_DIR / "engine" / "profiles" + def load_profile(profile_name: str) -> ProfileConfig: - base_dir = os.path.join(os.path.dirname(__file__), "..", "..", "engine", "profiles") - path = os.path.join(base_dir, f"{profile_name}.json") - if not os.path.exists(path): + path = PROFILE_DIR / f"{profile_name}.json" + if not path.exists(): raise SystemExit(f"Profile not found: {profile_name} (expected {path})") - with open(path, "r", encoding="utf-8") as f: - data = json.load(f) + with path.open("r", encoding="utf-8") as profile_file: + data = json.load(profile_file) + + if not isinstance(data, dict): + raise SystemExit(f"Profile must be a JSON object: {path}") + return ProfileConfig(**data) def load_tasks(path: str) -> List[Task]: - with open(path, "r", encoding="utf-8") as f: - raw = json.load(f) + task_path = Path(path) + with task_path.open("r", encoding="utf-8") as task_file: + raw = json.load(task_file) + + if not isinstance(raw, list): + raise SystemExit(f"Task input must be a JSON array: {task_path}") tasks: List[Task] = [] - for item in raw: - tasks.append(Task(**item)) + for index, item in enumerate(raw): + if not isinstance(item, dict): + raise SystemExit(f"Task at index {index} must be a JSON object") + + task_data = dict(item) + due_date = task_data.get("due_date") + if isinstance(due_date, str): + try: + task_data["due_date"] = date.fromisoformat(due_date) + except ValueError as exc: + raise SystemExit( + f"Task at index {index} has an invalid due_date; use YYYY-MM-DD" + ) from exc + + tasks.append(Task(**task_data)) + return tasks +def _utc_timestamp(timestamp) -> str: + if timestamp.tzinfo is None: + timestamp = timestamp.replace(tzinfo=timezone.utc) + return timestamp.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") + + def save_log(plan, profile: ProfileConfig, log_dir: str = "logs") -> str: - os.makedirs(log_dir, exist_ok=True) + log_directory = Path(log_dir) + log_directory.mkdir(parents=True, exist_ok=True) + log = RunLog( timestamp=plan.timestamp, profile_name=profile.name, input_task_count=len(plan.big_focus) + len(plan.support_tasks) + len(plan.parked_tasks), - big_focus_ids=[t.id for t in plan.big_focus], + big_focus_ids=[task.id for task in plan.big_focus], engine_version=plan.engine_version, decision_summary=plan.decision_summary, ) + record = { - "timestamp": log.timestamp.isoformat() + "Z", + "timestamp": _utc_timestamp(log.timestamp), "profile": log.profile_name, "input_task_count": log.input_task_count, "big_focus_ids": log.big_focus_ids, "engine_version": log.engine_version, "decision_summary": log.decision_summary, } + filename = f"dailypilot_run_{log.timestamp.strftime('%Y%m%dT%H%M%S')}.json" - path = os.path.join(log_dir, filename) - with open(path, "w", encoding="utf-8") as f: - json.dump(record, f, indent=2) - return path + path = log_directory / filename + with path.open("w", encoding="utf-8") as log_file: + json.dump(record, log_file, indent=2) + + return str(path) def main() -> None: - parser = argparse.ArgumentParser(description="Run DailyPilot-Engine on a JSON task file.") - parser.add_argument("tasks", help="Path to a JSON file containing a list of tasks.") + parser = argparse.ArgumentParser( + description="Run the DailyPilot reference engine on a JSON task file." + ) + parser.add_argument("tasks", help="Path to a JSON array of tasks") parser.add_argument( "--profile", default="worker_double_shift", - help="Profile name to use (default: worker_double_shift).", + help="Profile name (default: worker_double_shift)", ) args = parser.parse_args() @@ -71,34 +108,29 @@ def main() -> None: scored = score_tasks(tasks, profile) plan = build_daily_plan(scored, profile) - print("") - print("=== DailyPilot-Engine Plan ===") + print("\n=== DailyPilot-Engine Plan ===") print(f"Profile: {plan.profile_name}") - print(f"Timestamp (UTC): {plan.timestamp.isoformat()}Z") - print("") - print("Today’s Focus:") - for t in plan.big_focus: - print(f"- {t.title} (id={t.id})") + print(f"Timestamp (UTC): {_utc_timestamp(plan.timestamp)}") + + print("\nToday’s Focus:") + for task in plan.big_focus: + print(f"- {task.title} (id={task.id})") if plan.support_tasks: - print("") - print("Support Tasks:") - for t in plan.support_tasks: - print(f"- {t.title} (id={t.id})") + print("\nSupport Tasks:") + for task in plan.support_tasks: + print(f"- {task.title} (id={task.id})") if plan.parked_tasks: - print("") - print("Parked Tasks:") - for t in plan.parked_tasks: - print(f"- {t.title} (id={t.id})") + print("\nParked Tasks:") + for task in plan.parked_tasks: + print(f"- {task.title} (id={task.id})") - print("") - print("Summary:") + print("\nSummary:") print(plan.decision_summary) log_path = save_log(plan, profile) - print("") - print(f"Log written to: {log_path}") + print(f"\nLog written to: {log_path}") if __name__ == "__main__": diff --git a/engine/__init__.py b/engine/__init__.py new file mode 100644 index 0000000..2a86f28 --- /dev/null +++ b/engine/__init__.py @@ -0,0 +1,3 @@ +"""DailyPilot Engine reference package.""" + +__version__ = "0.1.0" diff --git a/engine/core/__init__.py b/engine/core/__init__.py new file mode 100644 index 0000000..deded83 --- /dev/null +++ b/engine/core/__init__.py @@ -0,0 +1,15 @@ +"""Core data models, scoring, and plan selection for DailyPilot Engine.""" + +from .models import Plan, ProfileConfig, RunLog, ScoredTask, Task +from .scoring import score_tasks +from .selectors import build_daily_plan + +__all__ = [ + "Plan", + "ProfileConfig", + "RunLog", + "ScoredTask", + "Task", + "build_daily_plan", + "score_tasks", +] diff --git a/models.py b/engine/core/models.py similarity index 67% rename from models.py rename to engine/core/models.py index f21b58d..c5e3eea 100644 --- a/models.py +++ b/engine/core/models.py @@ -1,6 +1,6 @@ from dataclasses import dataclass, field -from datetime import datetime, date -from typing import List, Optional, Dict, Any +from datetime import date, datetime +from typing import Any, Dict, List, Optional @dataclass @@ -12,28 +12,28 @@ class Task: description: str = "" importance: int = 3 urgency: int = 3 - effort_estimate: float = 1.0 # in hours - stress_impact: str = "MEDIUM" # LOW | MEDIUM | HIGH + effort_estimate: float = 1.0 + stress_impact: str = "MEDIUM" due_date: Optional[date] = None - time_window: Optional[str] = None # free text for now + time_window: Optional[str] = None category: Optional[str] = None meta: Dict[str, Any] = field(default_factory=dict) @dataclass class ProfileConfig: - """Configuration that tunes how the engine behaves for a user or context.""" + """Configuration that tunes engine behavior for a user or context.""" name: str daily_effort_budget_hours: float = 6.0 max_big_focus: int = 3 weights: Dict[str, float] = field(default_factory=dict) - max_stress_load: float = 1.0 # 0–1 scale + max_stress_load: float = 1.0 @dataclass class ScoredTask: - """Wrapper around a Task with a computed score and explanation.""" + """A task with a computed score and inspectable factor breakdown.""" task: Task score: float @@ -55,7 +55,7 @@ class Plan: @dataclass class RunLog: - """Minimal structured log record, suitable for AINet / ledger export.""" + """Minimal structured record for local audit or ledger export.""" timestamp: datetime profile_name: str diff --git a/scoring.py b/engine/core/scoring.py similarity index 81% rename from scoring.py rename to engine/core/scoring.py index 775572d..b2292a3 100644 --- a/scoring.py +++ b/engine/core/scoring.py @@ -1,6 +1,7 @@ from datetime import date -from typing import List, Dict -from .models import Task, ProfileConfig, ScoredTask +from typing import Dict, List + +from .models import ProfileConfig, ScoredTask, Task def _normalize(value: float, min_value: float, max_value: float) -> float: @@ -20,9 +21,11 @@ def _stress_penalty(stress_impact: str) -> float: def score_tasks(tasks: List[Task], profile: ProfileConfig) -> List[ScoredTask]: - """Score tasks for a given profile. + """Score tasks using an inspectable heuristic for a given profile. - This is a simple, transparent scoring model meant as a solid starting point. + The result is decision support, not an autonomous or high-stakes decision. + Callers remain responsible for reviewing deadlines, safety constraints, + accessibility needs, and other context the heuristic does not represent. """ weights: Dict[str, float] = { @@ -73,5 +76,5 @@ def score_tasks(tasks: List[Task], profile: ProfileConfig) -> List[ScoredTask]: scored.append(ScoredTask(task=task, score=final_score, breakdown=breakdown)) - scored.sort(key=lambda st: st.score, reverse=True) + scored.sort(key=lambda scored_task: scored_task.score, reverse=True) return scored diff --git a/selectors.py b/engine/core/selectors.py similarity index 66% rename from selectors.py rename to engine/core/selectors.py index b7a1d13..2c66142 100644 --- a/selectors.py +++ b/engine/core/selectors.py @@ -1,16 +1,16 @@ -from datetime import datetime +from datetime import datetime, timezone from typing import List -from .models import ScoredTask, ProfileConfig, Plan, Task + +from .models import Plan, ProfileConfig, ScoredTask, Task def build_daily_plan(scored_tasks: List[ScoredTask], profile: ProfileConfig) -> Plan: - """Turn scored tasks into a daily plan. + """Build a bounded plan from tasks already ordered by score. - Strategy: - - Walk tasks in order of score. - - Add tasks to big_focus until we hit max_big_focus or run out. - - Add additional tasks as support if there is budget left. - - Anything that does not fit within the effort budget is parked. + The selector respects the configured effort budget and focus-task limit. + It does not understand calendar conflicts, dependencies, safety-critical + obligations, or consequences beyond the fields supplied by the caller. + A human should review the resulting plan before acting on it. """ effort_budget = profile.daily_effort_budget_hours @@ -20,7 +20,7 @@ def build_daily_plan(scored_tasks: List[ScoredTask], profile: ProfileConfig) -> support_tasks: List[Task] = [] parked_tasks: List[Task] = [] - for idx, scored in enumerate(scored_tasks): + for scored in scored_tasks: task = scored.task task_effort = max(0.0, task.effort_estimate) @@ -40,12 +40,11 @@ def build_daily_plan(scored_tasks: List[ScoredTask], profile: ProfileConfig) -> f"Parked {len(parked_tasks)} task(s)." ) - plan = Plan( + return Plan( profile_name=profile.name, - timestamp=datetime.utcnow(), + timestamp=datetime.now(timezone.utc), big_focus=big_focus, support_tasks=support_tasks, parked_tasks=parked_tasks, decision_summary=summary, ) - return plan diff --git a/founder.json b/engine/profiles/founder.json similarity index 99% rename from founder.json rename to engine/profiles/founder.json index 51fee50..98de6e9 100644 --- a/founder.json +++ b/engine/profiles/founder.json @@ -9,4 +9,4 @@ "effort": 0.15, "deadline": 0.1 } -} \ No newline at end of file +} diff --git a/student.json b/engine/profiles/student.json similarity index 99% rename from student.json rename to engine/profiles/student.json index de791f8..4a9dd8f 100644 --- a/student.json +++ b/engine/profiles/student.json @@ -9,4 +9,4 @@ "effort": 0.2, "deadline": 0.1 } -} \ No newline at end of file +} diff --git a/worker_double_shift.json b/engine/profiles/worker_double_shift.json similarity index 99% rename from worker_double_shift.json rename to engine/profiles/worker_double_shift.json index 642b4de..0702acb 100644 --- a/worker_double_shift.json +++ b/engine/profiles/worker_double_shift.json @@ -9,4 +9,4 @@ "effort": 0.2, "deadline": 0.1 } -} \ No newline at end of file +} diff --git a/test_profiles.py b/test_profiles.py index 3502b07..49e3c9a 100644 --- a/test_profiles.py +++ b/test_profiles.py @@ -1,12 +1,26 @@ import json -import os +from pathlib import Path -def test_profiles_are_valid_json(): - base_dir = os.path.join(os.path.dirname(__file__), "..", "engine", "profiles") - for name in os.listdir(base_dir): - if not name.endswith(".json"): - continue - path = os.path.join(base_dir, name) - with open(path, "r", encoding="utf-8") as f: - json.load(f) +PROFILE_NAMES = { + "student.json", + "worker_double_shift.json", + "founder.json", +} + + +def test_profiles_are_valid_json_objects(): + base_dir = Path(__file__).resolve().parent / "engine" / "profiles" + discovered = {path.name for path in base_dir.glob("*.json")} + + assert PROFILE_NAMES.issubset(discovered) + + for name in PROFILE_NAMES: + path = base_dir / name + with path.open("r", encoding="utf-8") as profile_file: + profile = json.load(profile_file) + + assert isinstance(profile, dict) + assert profile.get("name") + assert profile.get("daily_effort_budget_hours", 0) > 0 + assert profile.get("max_big_focus", 0) >= 1 diff --git a/test_scoring.py b/test_scoring.py index d9d7195..0813f72 100644 --- a/test_scoring.py +++ b/test_scoring.py @@ -1,5 +1,6 @@ -import datetime -from engine.core.models import Task, ProfileConfig +from datetime import date, timedelta + +from engine.core.models import ProfileConfig, Task from engine.core.scoring import score_tasks @@ -9,4 +10,41 @@ def test_higher_importance_scores_higher(): high = Task(id="high", title="High importance", importance=5, urgency=3) scored = score_tasks([low, high], profile) + assert scored[0].task.id == "high" + + +def test_nearer_deadline_scores_higher_when_other_fields_match(): + profile = ProfileConfig(name="test_profile") + due_today = Task( + id="today", + title="Due today", + due_date=date.today(), + stress_impact="LOW", + ) + due_later = Task( + id="later", + title="Due later", + due_date=date.today() + timedelta(days=14), + stress_impact="LOW", + ) + + scored = score_tasks([due_later, due_today], profile) + + assert scored[0].task.id == "today" + + +def test_score_includes_inspectable_breakdown(): + profile = ProfileConfig(name="test_profile") + task = Task(id="task", title="Task") + + result = score_tasks([task], profile)[0] + + assert set(result.breakdown) == { + "importance_n", + "urgency_n", + "effort_n", + "deadline_n", + "stress_penalty", + "base_score", + } diff --git a/test_selectors.py b/test_selectors.py index a8d10e9..c10cad3 100644 --- a/test_selectors.py +++ b/test_selectors.py @@ -1,17 +1,48 @@ -from engine.core.models import Task, ProfileConfig, ScoredTask +from engine.core.models import ProfileConfig, ScoredTask, Task from engine.core.selectors import build_daily_plan -def test_big_focus_respects_budget_and_limit(): - profile = ProfileConfig(name="test_profile", daily_effort_budget_hours=3.0, max_big_focus=2) +def _scored(task_id: str, effort: float, score: float) -> ScoredTask: + return ScoredTask( + task=Task(id=task_id, title=task_id.upper(), effort_estimate=effort), + score=score, + breakdown={}, + ) + +def test_big_focus_respects_budget_and_limit(): + profile = ProfileConfig( + name="test_profile", + daily_effort_budget_hours=3.0, + max_big_focus=2, + ) tasks = [ - ScoredTask(task=Task(id="a", title="A", effort_estimate=1.0), score=1.0, breakdown={}), - ScoredTask(task=Task(id="b", title="B", effort_estimate=1.0), score=0.9, breakdown={}), - ScoredTask(task=Task(id="c", title="C", effort_estimate=2.0), score=0.8, breakdown={}), + _scored("a", 1.0, 1.0), + _scored("b", 1.0, 0.9), + _scored("c", 2.0, 0.8), ] plan = build_daily_plan(tasks, profile) + assert len(plan.big_focus) <= profile.max_big_focus - total_effort = sum(t.effort_estimate for t in plan.big_focus + plan.support_tasks) + total_effort = sum( + task.effort_estimate for task in plan.big_focus + plan.support_tasks + ) assert total_effort <= profile.daily_effort_budget_hours + 1e-6 + + +def test_tasks_that_exceed_budget_are_parked(): + profile = ProfileConfig( + name="test_profile", + daily_effort_budget_hours=1.0, + max_big_focus=1, + ) + tasks = [ + _scored("fits", 1.0, 1.0), + _scored("too_large", 2.0, 0.9), + ] + + plan = build_daily_plan(tasks, profile) + + assert [task.id for task in plan.big_focus] == ["fits"] + assert [task.id for task in plan.parked_tasks] == ["too_large"]