generated from eblume/project-template
Phase 1: v1 prototype #1
9 changed files with 716 additions and 0 deletions
heph-core: "what is next?" ranking (tech-spec §7)
Some checks failed
Build / validate (pull_request) Failing after 3s
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>
commit
7f63f926d0
167
Cargo.lock
generated
167
Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -13,3 +13,6 @@ rusqlite.workspace = true
|
|||
ulid.workspace = true
|
||||
thiserror.workspace = true
|
||||
pulldown-cmark.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
proptest = "1"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
383
crates/heph-core/src/ranking.rs
Normal file
383
crates/heph-core/src/ranking.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
95
crates/heph-core/tests/next_ranking.rs
Normal file
95
crates/heph-core/tests/next_ranking.rs
Normal 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()]);
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue