Phase 1: v1 prototype #1

Merged
eblume merged 91 commits from feature/v1-prototype into main 2026-06-03 20:48:23 -07:00
9 changed files with 716 additions and 0 deletions
Showing only changes of commit 7f63f926d0 - Show all commits

heph-core: "what is next?" ranking (tech-spec §7)
Some checks failed
Build / validate (pull_request) Failing after 3s

Slice 4 — the flagship Tactical blank-slate engine. Pure and
clock-injected, two stages:

- Candidacy filter: committed ∧ outstanding ∧ ¬tombstoned ∧ ≠blue ∧
  actionable (do_date NULL or ≤ now) ∧ in scope. do_date is used ONLY
  here — a boolean "can I do this now?" gate, never urgency.
- Order: an ordered list of named Dimensions applied lexicographically
  (PastLateOn → LateOverdueAmount → Attention band → CreatedAt FIFO),
  with node_id as final tiebreak for a total order. Reorder RANKING in
  one place to retune. late_on is the sole urgency signal (global tier);
  age never becomes urgency. blue hidden; red always shown past limit.

Storage `Store::next` loads candidates via a SQL join (project +
canonical-context links) and runs the pure engine with the store clock.

13 table-driven unit cases + 3 proptests (antisymmetry, sorted output
fully ordered, equality ⇒ identity) + 2 end-to-end. 38 tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Erich Blume 2026-05-31 19:07:16 -07:00

167
Cargo.lock generated
View file

@ -14,6 +14,27 @@ dependencies = [
"zerocopy",
]
[[package]]
name = "autocfg"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "bit-set"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
dependencies = [
"bit-vec",
]
[[package]]
name = "bit-vec"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
[[package]]
name = "bitflags"
version = "2.11.1"
@ -42,6 +63,16 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys",
]
[[package]]
name = "fallible-iterator"
version = "0.3.0"
@ -54,12 +85,24 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
[[package]]
name = "fastrand"
version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "fnv"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
name = "futures-core"
version = "0.3.32"
@ -118,6 +161,7 @@ dependencies = [
name = "heph-core"
version = "0.0.0"
dependencies = [
"proptest",
"pulldown-cmark",
"rusqlite",
"thiserror",
@ -153,12 +197,27 @@ dependencies = [
"vcpkg",
]
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "memchr"
version = "2.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8"
[[package]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
]
[[package]]
name = "once_cell"
version = "1.21.4"
@ -195,6 +254,25 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "proptest"
version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744"
dependencies = [
"bit-set",
"bit-vec",
"bitflags",
"num-traits",
"rand",
"rand_chacha",
"rand_xorshift",
"regex-syntax",
"rusty-fork",
"tempfile",
"unarray",
]
[[package]]
name = "pulldown-cmark"
version = "0.13.4"
@ -206,6 +284,12 @@ dependencies = [
"unicase",
]
[[package]]
name = "quick-error"
version = "1.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0"
[[package]]
name = "quote"
version = "1.0.45"
@ -250,6 +334,21 @@ dependencies = [
"getrandom",
]
[[package]]
name = "rand_xorshift"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a"
dependencies = [
"rand_core",
]
[[package]]
name = "regex-syntax"
version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]]
name = "rusqlite"
version = "0.32.1"
@ -264,12 +363,37 @@ dependencies = [
"smallvec",
]
[[package]]
name = "rustix"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys",
"windows-sys",
]
[[package]]
name = "rustversion"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "rusty-fork"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2"
dependencies = [
"fnv",
"quick-error",
"tempfile",
"wait-timeout",
]
[[package]]
name = "shlex"
version = "2.0.1"
@ -299,6 +423,19 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "tempfile"
version = "3.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom",
"once_cell",
"rustix",
"windows-sys",
]
[[package]]
name = "thiserror"
version = "2.0.18"
@ -329,6 +466,12 @@ dependencies = [
"web-time",
]
[[package]]
name = "unarray"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94"
[[package]]
name = "unicase"
version = "2.9.0"
@ -353,6 +496,15 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "wait-timeout"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11"
dependencies = [
"libc",
]
[[package]]
name = "wasip2"
version = "1.0.3+wasi-0.2.9"
@ -417,6 +569,21 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "wit-bindgen"
version = "0.57.1"

View file

@ -13,3 +13,6 @@ rusqlite.workspace = true
ulid.workspace = true
thiserror.workspace = true
pulldown-cmark.workspace = true
[dev-dependencies]
proptest = "1"

View file

@ -12,6 +12,7 @@ pub mod clock;
pub mod error;
pub mod extract;
pub mod model;
pub mod ranking;
pub mod sqlite;
pub mod store;
@ -19,5 +20,6 @@ pub use clock::{Clock, FixedClock};
pub use error::{Error, Result};
pub use extract::{extract, ContextItem, Extraction};
pub use model::{Attention, Link, LinkType, NewNode, NewTask, Node, NodeKind, Task, TaskState};
pub use ranking::{rank, Dimension, RankedTask, RANKING};
pub use sqlite::LocalStore;
pub use store::Store;

View file

@ -0,0 +1,383 @@
//! The "what is next?" ranking — the Tactical blank-slate engine (tech-spec §7).
//!
//! Pure and clock-injected. Two stages:
//!
//! 1. **Filter (candidacy)** — a predicate. `do_date` is used *only here*, as a
//! boolean "can this be done now?" gate, never as urgency.
//! 2. **Order** — an ordered list of named [`Dimension`]s applied
//! lexicographically. Reordering [`RANKING`] (one place) reshapes the
//! ranking without touching the comparison logic.
//!
//! `late_on` is the **sole** urgency signal: items past `late_on` float above
//! everything (incl. `red`), most-past first. Age never becomes urgency — the
//! only within-band tiebreak is `created_at` ascending (FIFO).
use std::cmp::Ordering;
use crate::model::{Attention, TaskState};
/// A task as seen by the ranking engine — the candidacy fields plus the bits
/// the Tactical output row shows. Used as both input and output of [`rank`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RankedTask {
/// The task node id.
pub node_id: String,
/// Title (for the output row).
pub title: String,
/// Attention-state.
pub attention: Option<Attention>,
/// Earliest-actionable date (candidacy gate only).
pub do_date: Option<i64>,
/// Lateness-problem marker (the sole urgency signal).
pub late_on: Option<i64>,
/// Lifecycle state.
pub state: TaskState,
/// Whether tombstoned.
pub tombstoned: bool,
/// The task's project node id, if any (for `scope`).
pub project_id: Option<String>,
/// The task's canonical context doc id (the one-keystroke jump).
pub canonical_context_id: Option<String>,
/// Creation time, epoch ms (FIFO tiebreak).
pub created_at: i64,
}
/// A named sort dimension. The order relation is the [`RANKING`] list applied
/// lexicographically; this enum is what makes that list reorderable as data.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Dimension {
/// Past `late_on` (a global top tier). Descending: past-late floats up.
PastLateOn,
/// How far past `late_on`. Descending: most-overdue first.
LateOverdueAmount,
/// Attention band. `red` → `orange` → `white`.
Attention,
/// Creation time. Ascending (FIFO).
CreatedAt,
}
/// The ranking order, as data. Reorder this to retune the sort.
pub const RANKING: &[Dimension] = &[
Dimension::PastLateOn,
Dimension::LateOverdueAmount,
Dimension::Attention,
Dimension::CreatedAt,
];
/// Filter to candidates, order by [`RANKING`], and apply `limit` (default
/// callers pass 5). `red` items always appear regardless of `limit`.
///
/// `scope`, when `Some`, restricts to tasks in that project node id.
pub fn rank(
tasks: Vec<RankedTask>,
now: i64,
scope: Option<&str>,
limit: usize,
) -> Vec<RankedTask> {
let mut candidates: Vec<RankedTask> = tasks
.into_iter()
.filter(|t| is_candidate(t, now, scope))
.collect();
candidates.sort_by(|a, b| order(a, b, now));
candidates
.into_iter()
.enumerate()
.filter(|(i, t)| *i < limit || t.attention == Some(Attention::Red))
.map(|(_, t)| t)
.collect()
}
/// The candidacy predicate (stage 1). `committed` is implicit — every
/// [`RankedTask`] is a committed task.
pub fn is_candidate(t: &RankedTask, now: i64, scope: Option<&str>) -> bool {
t.state == TaskState::Outstanding
&& !t.tombstoned
&& t.attention != Some(Attention::Blue)
&& t.do_date.is_none_or(|d| d <= now)
&& scope.is_none_or(|s| t.project_id.as_deref() == Some(s))
}
/// The full order relation (stage 2): [`RANKING`] applied lexicographically,
/// with `node_id` as a final tiebreak so the order is total and deterministic.
pub fn order(a: &RankedTask, b: &RankedTask, now: i64) -> Ordering {
RANKING
.iter()
.fold(Ordering::Equal, |acc, &dim| {
acc.then_with(|| compare(dim, a, b, now))
})
.then_with(|| a.node_id.cmp(&b.node_id))
}
fn compare(dim: Dimension, a: &RankedTask, b: &RankedTask, now: i64) -> Ordering {
match dim {
// Descending: past-late (true) sorts before not-past (false).
Dimension::PastLateOn => past_late_on(b, now).cmp(&past_late_on(a, now)),
// Descending: larger overdue amount first.
Dimension::LateOverdueAmount => overdue_amount(b, now).cmp(&overdue_amount(a, now)),
// Ascending by band index: red(0) < orange(1) < white(2).
Dimension::Attention => attention_rank(a.attention).cmp(&attention_rank(b.attention)),
// Ascending: oldest first (FIFO).
Dimension::CreatedAt => a.created_at.cmp(&b.created_at),
}
}
fn past_late_on(t: &RankedTask, now: i64) -> bool {
t.late_on.is_some_and(|l| now > l)
}
fn overdue_amount(t: &RankedTask, now: i64) -> i64 {
t.late_on.map_or(0, |l| now.saturating_sub(l).max(0))
}
fn attention_rank(a: Option<Attention>) -> u8 {
match a {
Some(Attention::Red) => 0,
Some(Attention::Orange) => 1,
Some(Attention::White) => 2,
// Blue is filtered out before ordering; None ranks last.
Some(Attention::Blue) => 3,
None => 4,
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Build a candidate with sensible defaults; override fields per test.
fn task(node_id: &str) -> RankedTask {
RankedTask {
node_id: node_id.to_string(),
title: node_id.to_string(),
attention: Some(Attention::White),
do_date: None,
late_on: None,
state: TaskState::Outstanding,
tombstoned: false,
project_id: None,
canonical_context_id: None,
created_at: 0,
}
}
const NOW: i64 = 1_000_000;
fn ids(ranked: &[RankedTask]) -> Vec<&str> {
ranked.iter().map(|t| t.node_id.as_str()).collect()
}
#[test]
fn empty_in_empty_out() {
assert!(rank(vec![], NOW, None, 5).is_empty());
}
#[test]
fn blue_is_hidden() {
let mut b = task("b");
b.attention = Some(Attention::Blue);
assert!(rank(vec![b], NOW, None, 5).is_empty());
}
#[test]
fn non_outstanding_is_filtered() {
let mut done = task("done");
done.state = TaskState::Done;
let mut dropped = task("dropped");
dropped.state = TaskState::Dropped;
assert!(rank(vec![done, dropped], NOW, None, 5).is_empty());
}
#[test]
fn tombstoned_is_filtered() {
let mut t = task("t");
t.tombstoned = true;
assert!(rank(vec![t], NOW, None, 5).is_empty());
}
#[test]
fn future_do_date_is_not_actionable_but_null_and_past_are() {
let mut future = task("future");
future.do_date = Some(NOW + 1);
let mut today = task("today");
today.do_date = Some(NOW);
let null = task("null");
assert_eq!(
ids(&rank(vec![future, today, null], NOW, None, 5)),
vec!["null", "today"]
);
}
#[test]
fn attention_band_orders_red_orange_white() {
let mut red = task("red");
red.attention = Some(Attention::Red);
let mut orange = task("orange");
orange.attention = Some(Attention::Orange);
let white = task("white");
// Feed in a deliberately non-sorted order.
let out = rank(vec![white, red, orange], NOW, None, 5);
assert_eq!(ids(&out), vec!["red", "orange", "white"]);
}
#[test]
fn past_late_on_floats_above_red() {
// A white task past its late_on beats a red task that isn't late.
let mut late_white = task("late_white");
late_white.late_on = Some(NOW - 10);
let mut red = task("red");
red.attention = Some(Attention::Red);
let out = rank(vec![red, late_white], NOW, None, 5);
assert_eq!(ids(&out), vec!["late_white", "red"]);
}
#[test]
fn most_overdue_late_on_comes_first() {
let mut a = task("a");
a.late_on = Some(NOW - 5);
let mut b = task("b");
b.late_on = Some(NOW - 50);
let out = rank(vec![a, b], NOW, None, 5);
assert_eq!(ids(&out), vec!["b", "a"]);
}
#[test]
fn late_on_in_the_future_is_not_urgent() {
// late_on later than now is not "past late_on" — no urgency boost.
let mut not_yet = task("not_yet");
not_yet.late_on = Some(NOW + 100);
not_yet.attention = Some(Attention::White);
let mut orange = task("orange");
orange.attention = Some(Attention::Orange);
let out = rank(vec![not_yet, orange], NOW, None, 5);
assert_eq!(ids(&out), vec!["orange", "not_yet"]);
}
#[test]
fn fifo_tiebreak_within_band() {
let mut older = task("older");
older.created_at = 1;
let mut newer = task("newer");
newer.created_at = 2;
let out = rank(vec![newer, older], NOW, None, 5);
assert_eq!(ids(&out), vec!["older", "newer"]);
}
#[test]
fn scope_restricts_to_a_project() {
let mut in_p = task("in_p");
in_p.project_id = Some("proj".into());
let mut other = task("other");
other.project_id = Some("nope".into());
let none = task("none");
let out = rank(vec![in_p, other, none], NOW, Some("proj"), 5);
assert_eq!(ids(&out), vec!["in_p"]);
}
#[test]
fn limit_truncates_non_red_but_red_always_appears() {
let mut whites: Vec<RankedTask> = (0..5)
.map(|i| {
let mut t = task(&format!("w{i}"));
t.created_at = i;
t
})
.collect();
// A red created last → sorts after the whites by band? No: red band wins,
// so red sorts first. Put it at the front of results regardless of limit.
let mut red = task("red");
red.attention = Some(Attention::Red);
red.created_at = 100;
whites.push(red);
let out = rank(whites, NOW, None, 2);
// limit 2 → red + first white; red guaranteed present.
assert!(out.iter().any(|t| t.node_id == "red"));
assert!(out.len() <= 3);
}
#[test]
fn red_beyond_limit_is_still_included() {
// Many past-late_on whites fill the limit; reds after them still appear.
let mut tasks: Vec<RankedTask> = (0..5)
.map(|i| {
let mut t = task(&format!("late{i}"));
t.late_on = Some(NOW - 1000 - i); // all past late_on
t.created_at = i;
t
})
.collect();
let mut red = task("red");
red.attention = Some(Attention::Red);
tasks.push(red);
let out = rank(tasks, NOW, None, 3);
assert!(out.iter().any(|t| t.node_id == "red"));
}
// --- property tests: the ordering is a total order (tech-spec §9) ---
use proptest::prelude::*;
fn attention_strategy() -> impl Strategy<Value = Option<Attention>> {
prop_oneof![
Just(None),
Just(Some(Attention::Red)),
Just(Some(Attention::Orange)),
Just(Some(Attention::White)),
Just(Some(Attention::Blue)),
]
}
prop_compose! {
fn task_strategy()(
id in 0u32..50,
attention in attention_strategy(),
late_on in proptest::option::of(-1_000_000_000i64..1_000_000_000),
created_at in -1_000_000_000i64..1_000_000_000,
) -> RankedTask {
let mut t = task(&format!("n{id:03}"));
t.attention = attention;
t.late_on = late_on;
t.created_at = created_at;
t
}
}
proptest! {
/// Antisymmetry: order(a,b) is exactly the reverse of order(b,a).
#[test]
fn order_is_antisymmetric(
a in task_strategy(),
b in task_strategy(),
now in -2_000_000_000i64..2_000_000_000,
) {
prop_assert_eq!(order(&a, &b, now), order(&b, &a, now).reverse());
}
/// Sorting yields a sequence with no out-of-order adjacent pair —
/// i.e. the relation is a consistent total order over any input.
#[test]
fn sorted_output_is_fully_ordered(
mut tasks in proptest::collection::vec(task_strategy(), 0..12),
now in -2_000_000_000i64..2_000_000_000,
) {
tasks.sort_by(|x, y| order(x, y, now));
for w in tasks.windows(2) {
prop_assert_ne!(order(&w[0], &w[1], now), Ordering::Greater);
}
}
/// Equality of the relation implies identity (node_id is the final
/// tiebreak), so the order is strict/total, never merely a preorder.
#[test]
fn equal_only_when_same_node(
a in task_strategy(),
b in task_strategy(),
now in -2_000_000_000i64..2_000_000_000,
) {
if order(&a, &b, now) == Ordering::Equal {
prop_assert_eq!(a.node_id, b.node_id);
}
}
}
}

View file

@ -24,6 +24,7 @@ use ulid::Ulid;
use crate::clock::Clock;
use crate::error::Result;
use crate::model::{Attention, Link, LinkType, NewNode, NewTask, Node, Task, TaskState};
use crate::ranking::RankedTask;
use crate::store::Store;
/// A SQLite file (or in-memory database) opened directly as a backend.
@ -136,6 +137,11 @@ impl Store for LocalStore {
tasks::set_attention(&self.conn, now, node_id, attention)
}
fn next(&self, scope: Option<&str>, limit: usize) -> Result<Vec<RankedTask>> {
let now = self.clock.now_ms();
tasks::next(&self.conn, &self.owner_id, now, scope, limit)
}
fn add_link(&mut self, src_id: &str, dst_id: &str, link_type: LinkType) -> Result<Link> {
let now = self.clock.now_ms();
links::add(&self.conn, now, src_id, dst_id, link_type)

View file

@ -8,6 +8,7 @@ use rusqlite::{Connection, OptionalExtension, Row};
use super::{links, nodes};
use crate::error::{Error, Result};
use crate::model::{Attention, LinkType, NewTask, NodeKind, Task, TaskState};
use crate::ranking::{self, RankedTask};
fn from_row(row: &Row) -> rusqlite::Result<Task> {
let attention = match row.get::<_, Option<String>>("attention")? {
@ -117,6 +118,58 @@ pub(super) fn set_state(
require(conn, node_id)
}
/// The Tactical "what is next?" ranking for `owner` at `now` (tech-spec §7).
pub(super) fn next(
conn: &Connection,
owner: &str,
now: i64,
scope: Option<&str>,
limit: usize,
) -> Result<Vec<RankedTask>> {
let candidates = load_candidates(conn, owner)?;
Ok(ranking::rank(candidates, now, scope, limit))
}
/// Load every non-tombstoned committed task for `owner` as a ranking candidate,
/// joining in its project and canonical-context link targets.
fn load_candidates(conn: &Connection, owner: &str) -> Result<Vec<RankedTask>> {
let sql = "
SELECT n.id, n.title, n.created_at, n.tombstoned,
t.attention, t.do_date, t.late_on, t.state,
(SELECT dst_id FROM links
WHERE src_id = n.id AND type = 'in-project' AND tombstoned = 0
ORDER BY created_at, id LIMIT 1) AS project_id,
(SELECT dst_id FROM links
WHERE src_id = n.id AND type = 'canonical-context' AND tombstoned = 0
ORDER BY created_at, id LIMIT 1) AS ctx_id
FROM tasks t JOIN nodes n ON n.id = t.node_id
WHERE n.owner_id = ?1 AND n.tombstoned = 0";
let mut stmt = conn.prepare(sql)?;
let rows = stmt.query_map([owner], |row| {
let attention = match row.get::<_, Option<String>>("attention")? {
Some(s) => Some(
Attention::parse(&s)
.map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?,
),
None => None,
};
Ok(RankedTask {
node_id: row.get("id")?,
title: row.get("title")?,
attention,
do_date: row.get("do_date")?,
late_on: row.get("late_on")?,
state: TaskState::parse(&row.get::<_, String>("state")?)
.map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?,
tombstoned: row.get::<_, i64>("tombstoned")? != 0,
project_id: row.get("project_id")?,
canonical_context_id: row.get("ctx_id")?,
created_at: row.get("created_at")?,
})
})?;
Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
}
/// Set a task's attention-state.
pub(super) fn set_attention(
conn: &Connection,

View file

@ -6,6 +6,7 @@
use crate::error::Result;
use crate::model::{Attention, Link, LinkType, NewNode, NewTask, Node, Task, TaskState};
use crate::ranking::RankedTask;
/// A backend that can store and retrieve nodes, tasks, and links.
///
@ -48,6 +49,11 @@ pub trait Store {
/// Set a task's attention-state.
fn set_task_attention(&mut self, node_id: &str, attention: Attention) -> Result<Task>;
/// The Tactical "what is next?" ranking (tech-spec §7), using the store's
/// injected clock as `now`. `scope`, when `Some`, restricts to a project
/// node id; `red` items always appear regardless of `limit`.
fn next(&self, scope: Option<&str>, limit: usize) -> Result<Vec<RankedTask>>;
// --- links ---
/// Add a typed link between two nodes.

View file

@ -0,0 +1,95 @@
//! End-to-end test of `Store::next` through the SQLite loader (slice 4).
//! The pure ranking is unit-tested in `ranking.rs`; here we prove the join
//! (project + canonical-context) and candidacy reach the engine intact.
use heph_core::{Attention, FixedClock, LocalStore, NewTask, Store, TaskState};
const NOW: i64 = 1_700_000_000_000;
fn store() -> LocalStore {
LocalStore::open_in_memory(Box::new(FixedClock(NOW))).unwrap()
}
#[test]
fn next_ranks_red_first_and_hides_blue_and_future() {
let mut s = store();
let red = s
.create_task(NewTask {
title: "Red now".into(),
attention: Some(Attention::Red),
..Default::default()
})
.unwrap();
let _white = s
.create_task(NewTask {
title: "White now".into(),
attention: Some(Attention::White),
..Default::default()
})
.unwrap();
let _blue = s
.create_task(NewTask {
title: "Blue backlog".into(),
attention: Some(Attention::Blue),
..Default::default()
})
.unwrap();
let _future = s
.create_task(NewTask {
title: "Not yet".into(),
attention: Some(Attention::Orange),
do_date: Some(NOW + 1_000),
..Default::default()
})
.unwrap();
let ranked = s.next(None, 5).unwrap();
let titles: Vec<&str> = ranked.iter().map(|t| t.title.as_str()).collect();
// Blue hidden, future not actionable → only the two "now" tasks, red first.
assert_eq!(titles, vec!["Red now", "White now"]);
// The canonical context link is surfaced for the one-keystroke jump.
assert_eq!(ranked[0].node_id, red.node_id);
assert!(ranked[0].canonical_context_id.is_some());
}
#[test]
fn next_respects_scope_and_excludes_completed() {
let mut s = store();
let project = s
.create_node(heph_core::NewNode {
kind: heph_core::NodeKind::Project,
title: "Work".into(),
body: None,
})
.unwrap();
let in_scope = s
.create_task(NewTask {
title: "Work task".into(),
attention: Some(Attention::Orange),
project_id: Some(project.id.clone()),
..Default::default()
})
.unwrap();
let _other = s
.create_task(NewTask {
title: "Life task".into(),
attention: Some(Attention::Orange),
..Default::default()
})
.unwrap();
let done = s
.create_task(NewTask {
title: "Already done".into(),
attention: Some(Attention::Orange),
project_id: Some(project.id.clone()),
..Default::default()
})
.unwrap();
s.set_task_state(&done.node_id, TaskState::Done).unwrap();
let ranked = s.next(Some(&project.id), 5).unwrap();
let ids: Vec<&str> = ranked.iter().map(|t| t.node_id.as_str()).collect();
assert_eq!(ids, vec![in_scope.node_id.as_str()]);
}

View file

@ -3,4 +3,5 @@ Begin the v1 prototype (Phase 1, tech-spec §11.1), built in TDD slices:
- Cargo workspace + `heph-core` crate; migration-run SQLite schema (§4.5); clock-injected `Store` trait + `LocalStore` node create/get; single local-user bootstrap.
- Markdown extraction (§5): `[[wiki-links]]` and GFM `- [ ]` checkbox context-items derived purely and idempotently from a body, skipping code blocks.
- Committed tasks (§4.3, §6): `task.create` auto-creates the canonical context `doc` + `canonical-context` link; attention/do-date/late-on/state/recurrence columns; set-state/set-attention. Links CRUD (outgoing/backlinks). A body update reconciles `wiki` links (diff-based, resolved by alias/title, idempotent).
- "What is next?" ranking (§7): pure, clock-injected, two-stage engine — candidacy filter (do-date as a boolean gate only) then a reorderable list of named dimensions (past-late-on → overdue-amount → attention band → FIFO). `late_on` is the sole urgency signal; blue hidden; red always shown. Proptest-checked total order. `Store::next` surfaces it over SQLite.
- CI runs the Rust suite (fmt/clippy/test) via the project build hook.