feat(domain): next-task policy filter and candidate window slicing #62

Merged
yuris merged 1 commit from feat/issue-22-filter into main 2026-09-23 00:16:43 +00:00
Owner

Motivation

life next must not hand task selection to a model first. docs/technical/jev.md#next-task-policy steps 1–2 require the deterministic constraints — lifecycle, commitment strength, explicit blockers, known window, hard timing and explicit energy — to be applied before any semantic ranking, with unknown attributes staying unknown and no blocker or capacity fact fabricated. This PR implements that half only: it produces the eligible candidate set, the factual reasons each candidate qualifies, the attributes each candidate leaves unknown, and one typed reason for every task it dropped.

Ordering (#23: deadline urgency → unmet weekday career floor → alignment.v1/consequence.v1/readiness.v1 → stable id) and the life next / POST /v1/next route (#24) are deliberately not here, and neither is any Jev call: the filter is deterministic and needs no provider.

Changes

  • src/domain/policy/next_task_filter.rs (new) — filter_candidate_tasks(tasks: Vec<ApiItem>, query: &NextTaskQuery, context: &FilterContext) -> NextTaskFilter. A pure function over the live inventory read (src/api/tasks.rs supplies the ApiItem snapshot; this module never reads a projection and never writes), following the decision-half pattern already used by src/domain/resolve.rs. Evaluation order, first decisive rule wins:
    1. Lifecycle — COMPLETED / CANCELLED are strict exclusions (case-insensitive, trimmed, compared against domain::lifecycle::TaskStatus's four tokens). A missing external STATUS is unspecified: not completed, and not active either. A token outside the four RFC 5545 states is reported as an unknown lifecycle state (unknowns: ["status"]) rather than guessed at.
    2. Scope — [planning] collection_ids, when non-empty, bounds candidacy; empty means every configured collection.
    3. Commitment strength — only life-active is a candidate. life-queued/life-someday are excluded, and a task with no strength category is strength_unknown (unknown strength is not active). A task carrying several strength categories is excluded and surfaced as invalid_policy_strength in warnings — an invalid-policy observation requiring correction, never a silent pick of one category.
    4. Hard timing — planned_start > now (instant) or a date-only start later than today is excluded; an instant exactly at now is not future.
    5. Blockers — matched on the canonical composite item.id (item_id_for) against FilterContext::active_blockers. A blocker is an explicit user fact; the UID or another item's id never matches, and nothing here infers one.
    6. Window — a known estimated_minutes above the known window is excluded (window_exceeded); a known estimate within it is stated as fitting. An unknown estimate is retained, listed in unknowns: ["estimated_minutes"] and never described as fitting (scenario W07).
    7. Energy — a requirement above what the caller has is excluded (so high is out at low and medium); unknown energy is retained and labelled unknowns: ["energy"], never assumed low.
    8. Area — the optional area-<label> restriction.
    9. Career floor — a qualifying area-career task under an unmet weekday floor is marked career_floor_priority: the eligibility signal for ranking, not the order.
  • Capacity is a declared fact, never an invention. NextTaskQuery.window_minutes (the caller's own statement) wins; otherwise the deployment's configured working_windows for today are summed when calendar coverage is complete; otherwise WindowCapacity::Unknown. Unknown capacity excludes nothing and claims no fit, and emits a capacity_unknown warning — the failure this forbids is summing "no working windows" to zero minutes and returning an empty agenda.
  • src/history/timeline.rs (new) — day_outcomes(db, &HalfOpenInterval) -> DayOutcomes { observed, qualifying } reads the append-only journal for explicit user-reported work-block outcomes (kind = work_block_outcome, evidence_class = user_report, qualifying on after_json.qualifies_career_floor or outcome = completed). Allocated blocks (confirmed_service) and reports whose actual time is unknown are not attributed to a day, so an allocation can never establish the career floor.
  • CareerFloor is tri-state. from_day_outcomes(observed, qualifying, floor) yields Met, Unmet (outcomes observed, fewer qualifying than [planning] weekday_career_floor) or Unknown (nothing observed — a gap in the record, not a failure), plus NotApplicable on weekends. The issue sketch's career_floor_met: bool could not express this distinction, which docs/technical/workflows.md#weekly-analytics requires.
  • src/domain/mod.rs — Energy gains Ord and parse/as_str, so a task's user-asserted requirement and the caller's available energy compare on one vocabulary, and an unrecognized token is None (unknown) rather than defaulted.
  • src/history/mod.rs — registers the timeline module.
  • tests/policy_filter_tests.rs (new) — 19 tests: the full exclusion matrix, the retention cases, career-floor classification and journal reads, capacity resolution, and live reads through a mock CalDAV server over TCP plus the daemon's single SQLite worker.

What #23 will consume

NextTaskFilter:

  • candidates: Vec<EligibleCandidate> — in the inventory read's order (a stable input; #23 owns the published order). Each candidate carries item: ApiItem (live deadline, project_id, categories, strength, etag…), factual_reasons: Vec<String>, unknowns: Vec<UnknownAttribute> (estimated_minutes / energy / window / status), is_career_task: bool and career_floor_priority: bool (the unmet-floor tie-break signal).
  • excluded: Vec<FilterDiagnostic> { item_id, title, reason: ExclusionReason } — one typed reason per dropped task, with reason.code() for machines and Display/message() for people.
  • warnings: Vec<WarningItem> — invalid_policy_strength, capacity_unknown.
  • capacity: WindowCapacity and career_floor: CareerFloor — echoed so a caller cannot re-derive them differently.

#23 computes deadline urgency itself from candidate.item.deadline; the filter deliberately produces no DeadlineUrgencyTier, since tiering is part of the ordering this issue leaves to #23, and unknowns/capacity/warnings are what #24 must disclose alongside the ranked result.

Verification

  • nix develop --command cargo test — all suites green, 0 failures (499 tests across 27 test binaries; 480 before this change + the 19 new ones in tests/policy_filter_tests.rs).
  • cargo clippy --all-targets -- -D warnings — clean.
  • cargo fmt then cargo fmt --check — clean.
  • New coverage in tests/policy_filter_tests.rs:
    • exclusion matrix — completed, cancelled (including lowercase/whitespace), unspecified and unrecognized statuses, life-someday, life-queued, missing strength, multiple strengths (excluded and warned), future planned_start (instant and date-only), active blocker by canonical composite id, 120m against a 90m window, high energy against low and medium, area mismatch, out-of-scope collection;
    • retention — unknown estimate and unknown energy retained, labelled, and asserted never to claim a fit;
    • career floor — met / unmet / unobserved classification, journal rows with a qualifying outcome, a non-qualifying report, an allocation row that must not count, an unknown-time report and a previous-day report that must not count, and "no observation is not a failure";
    • capacity — no configured windows ⇒ unknown capacity with candidates retained (not an empty agenda), configured Monday 09:00–12:00 ⇒ 180m honoured, incomplete calendar coverage ⇒ configured capacity downgraded to unknown;
    • live path — a mock CalDAV collection read through run_tasks, then filtered, asserting the exact candidate set and each exclusion reason, plus the default open read never handing over terminal tasks.

Notes and limits

  • The blocker set is an input: FilterContext::active_blockers holds canonical itm_… composite ids established by linked context (#18). This issue does not derive it from Hindsight, so no test exercises a Hindsight-derived blocker; #24 supplies the set (or an empty one) and the filter still never infers a blocker.
  • Explicit user policy is honoured where the configuration or the caller states it: weekday_career_floor (including 0, which makes the floor met by definition), collection_ids, working_windows, and the caller's explicitly stated window/energy/area, which override the configured declaration. The 40% unscheduled reserve belongs to proposal generation (#25–#27), not to this read-time filter.
  • Journal corrections (#29) are not folded here: day_outcomes reads the recorded outcome facts as written.

Closes #22

## Motivation `life next` must not hand task selection to a model first. `docs/technical/jev.md#next-task-policy` steps 1–2 require the deterministic constraints — lifecycle, commitment strength, explicit blockers, known window, hard timing and explicit energy — to be applied before any semantic ranking, with unknown attributes staying unknown and no blocker or capacity fact fabricated. This PR implements that half only: it produces the eligible candidate set, the factual reasons each candidate qualifies, the attributes each candidate leaves unknown, and one typed reason for every task it dropped. Ordering (#23: deadline urgency → unmet weekday career floor → `alignment.v1`/`consequence.v1`/`readiness.v1` → stable id) and the `life next` / `POST /v1/next` route (#24) are deliberately **not** here, and neither is any Jev call: the filter is deterministic and needs no provider. ## Changes - **`src/domain/policy/next_task_filter.rs` (new)** — `filter_candidate_tasks(tasks: Vec<ApiItem>, query: &NextTaskQuery, context: &FilterContext) -> NextTaskFilter`. A pure function over the live inventory read (`src/api/tasks.rs` supplies the `ApiItem` snapshot; this module never reads a projection and never writes), following the decision-half pattern already used by `src/domain/resolve.rs`. Evaluation order, first decisive rule wins: 1. **Lifecycle** — `COMPLETED` / `CANCELLED` are strict exclusions (case-insensitive, trimmed, compared against `domain::lifecycle::TaskStatus`'s four tokens). A missing external `STATUS` is *unspecified*: not completed, and not active either. A token outside the four RFC 5545 states is reported as an unknown lifecycle state (`unknowns: ["status"]`) rather than guessed at. 2. **Scope** — `[planning] collection_ids`, when non-empty, bounds candidacy; empty means every configured collection. 3. **Commitment strength** — only `life-active` is a candidate. `life-queued`/`life-someday` are excluded, and a task with no strength category is `strength_unknown` (unknown strength is not active). A task carrying *several* strength categories is excluded **and** surfaced as `invalid_policy_strength` in `warnings` — an invalid-policy observation requiring correction, never a silent pick of one category. 4. **Hard timing** — `planned_start > now` (instant) or a date-only start later than today is excluded; an instant exactly at `now` is not future. 5. **Blockers** — matched on the canonical composite `item.id` (`item_id_for`) against `FilterContext::active_blockers`. A blocker is an explicit user fact; the UID or another item's id never matches, and nothing here infers one. 6. **Window** — a known `estimated_minutes` above the known window is excluded (`window_exceeded`); a known estimate within it is stated as fitting. An unknown estimate is **retained**, listed in `unknowns: ["estimated_minutes"]` and never described as fitting (scenario W07). 7. **Energy** — a requirement above what the caller has is excluded (so `high` is out at `low` **and** `medium`); unknown energy is retained and labelled `unknowns: ["energy"]`, never assumed low. 8. **Area** — the optional `area-<label>` restriction. 9. **Career floor** — a qualifying `area-career` task under an unmet weekday floor is marked `career_floor_priority`: the eligibility signal for ranking, not the order. - **Capacity is a declared fact, never an invention.** `NextTaskQuery.window_minutes` (the caller's own statement) wins; otherwise the deployment's configured `working_windows` for today are summed when calendar coverage is complete; otherwise `WindowCapacity::Unknown`. Unknown capacity excludes nothing and claims no fit, and emits a `capacity_unknown` warning — the failure this forbids is summing "no working windows" to zero minutes and returning an empty agenda. - **`src/history/timeline.rs` (new)** — `day_outcomes(db, &HalfOpenInterval) -> DayOutcomes { observed, qualifying }` reads the append-only journal for explicit user-reported work-block outcomes (`kind = work_block_outcome`, `evidence_class = user_report`, qualifying on `after_json.qualifies_career_floor` or `outcome = completed`). Allocated blocks (`confirmed_service`) and reports whose actual time is unknown are not attributed to a day, so an allocation can never establish the career floor. - **`CareerFloor` is tri-state.** `from_day_outcomes(observed, qualifying, floor)` yields `Met`, `Unmet` (outcomes observed, fewer qualifying than `[planning] weekday_career_floor`) or `Unknown` (nothing observed — a gap in the record, not a failure), plus `NotApplicable` on weekends. The issue sketch's `career_floor_met: bool` could not express this distinction, which `docs/technical/workflows.md#weekly-analytics` requires. - **`src/domain/mod.rs`** — `Energy` gains `Ord` and `parse`/`as_str`, so a task's user-asserted requirement and the caller's available energy compare on one vocabulary, and an unrecognized token is `None` (unknown) rather than defaulted. - **`src/history/mod.rs`** — registers the timeline module. - **`tests/policy_filter_tests.rs` (new)** — 19 tests: the full exclusion matrix, the retention cases, career-floor classification and journal reads, capacity resolution, and live reads through a mock CalDAV server over TCP plus the daemon's single SQLite worker. ### What #23 will consume `NextTaskFilter`: - `candidates: Vec<EligibleCandidate>` — in the inventory read's order (a stable input; #23 owns the published order). Each candidate carries `item: ApiItem` (live `deadline`, `project_id`, `categories`, `strength`, `etag`…), `factual_reasons: Vec<String>`, `unknowns: Vec<UnknownAttribute>` (`estimated_minutes` / `energy` / `window` / `status`), `is_career_task: bool` and `career_floor_priority: bool` (the unmet-floor tie-break signal). - `excluded: Vec<FilterDiagnostic> { item_id, title, reason: ExclusionReason }` — one typed reason per dropped task, with `reason.code()` for machines and `Display`/`message()` for people. - `warnings: Vec<WarningItem>` — `invalid_policy_strength`, `capacity_unknown`. - `capacity: WindowCapacity` and `career_floor: CareerFloor` — echoed so a caller cannot re-derive them differently. #23 computes deadline urgency itself from `candidate.item.deadline`; the filter deliberately produces no `DeadlineUrgencyTier`, since tiering is part of the ordering this issue leaves to #23, and `unknowns`/`capacity`/`warnings` are what #24 must disclose alongside the ranked result. ## Verification - `nix develop --command cargo test` — all suites green, 0 failures (499 tests across 27 test binaries; 480 before this change + the 19 new ones in `tests/policy_filter_tests.rs`). - `cargo clippy --all-targets -- -D warnings` — clean. - `cargo fmt` then `cargo fmt --check` — clean. - New coverage in `tests/policy_filter_tests.rs`: - exclusion matrix — completed, cancelled (including lowercase/whitespace), unspecified and unrecognized statuses, `life-someday`, `life-queued`, missing strength, multiple strengths (excluded **and** warned), future `planned_start` (instant and date-only), active blocker by canonical composite id, 120m against a 90m window, `high` energy against `low` and `medium`, area mismatch, out-of-scope collection; - retention — unknown estimate and unknown energy retained, labelled, and asserted never to claim a fit; - career floor — met / unmet / unobserved classification, journal rows with a qualifying outcome, a non-qualifying report, an allocation row that must not count, an unknown-time report and a previous-day report that must not count, and "no observation is not a failure"; - capacity — no configured windows ⇒ unknown capacity with candidates retained (not an empty agenda), configured Monday 09:00–12:00 ⇒ 180m honoured, incomplete calendar coverage ⇒ configured capacity downgraded to unknown; - live path — a mock CalDAV collection read through `run_tasks`, then filtered, asserting the exact candidate set and each exclusion reason, plus the default open read never handing over terminal tasks. ## Notes and limits - The blocker set is an **input**: `FilterContext::active_blockers` holds canonical `itm_…` composite ids established by linked context (#18). This issue does not derive it from Hindsight, so no test exercises a Hindsight-derived blocker; #24 supplies the set (or an empty one) and the filter still never infers a blocker. - Explicit user policy is honoured where the configuration or the caller states it: `weekday_career_floor` (including `0`, which makes the floor met by definition), `collection_ids`, `working_windows`, and the caller's explicitly stated window/energy/area, which override the configured declaration. The 40% unscheduled reserve belongs to proposal generation (#25–#27), not to this read-time filter. - Journal corrections (#29) are not folded here: `day_outcomes` reads the recorded outcome facts as written. ## Related Issues Closes #22
Implement steps 1-2 of docs/technical/jev.md#next-task-policy: the
deterministic candidate set a next-task recommendation may be built from,
before any semantic ranking.

- src/domain/policy/next_task_filter.rs: pure filter over live ApiItems.
  Strictly excludes COMPLETED and CANCELLED; requires life-active strength
  (life-someday/life-queued excluded, no strength means unknown, not active);
  surfaces an item carrying several strengths as an invalid-policy warning
  rather than picking one; excludes future planned_start, canonical-id
  linked-context blockers, a known estimate that exceeds the known window and
  energy above what the caller has. Unknown estimate, energy, window or
  lifecycle state is retained and labelled unknown, never claimed to fit.
  Capacity comes from the caller's window, else from configured working
  windows for today; no windows means unknown capacity, never an empty agenda.
  Every dropped task carries one typed exclusion reason.
- src/history/timeline.rs: journal read of user-reported work-block outcomes
  per local day, from which CareerFloor::from_day_outcomes separates met,
  unmet and unobserved (a day with no observation is unknown, not a failure).
- tests/policy_filter_tests.rs: exclusion matrix, retention, career floor and
  capacity cases, plus live reads through a mock CalDAV server and the
  daemon's SQLite worker.

Ranking (#23) and the route (#24) remain separate work.
Author
Owner

Coordinator review — APPROVED

Verified c77ef83 myself: cargo test 499 passed / 0 failed (19 new policy-filter tests), cargo clippy --all-targets -- -D warnings 0 errors, cargo fmt --check clean.

What I checked beyond the summary:

  • "Unknown is not a value" is the module's organising principle, not an afterthought. Unknown attributes are enumerated in EligibleCandidate::unknowns so a later layer cannot quietly consume a missing fact as if it were present, unknown strength is never read as active, no configured working windows is WindowCapacity::Unknown (nothing excluded for exceeding it), and an unobserved day is CareerFloor::Unknown — distinct from Unmet, with write! rendering it as "unknown (no outcome observation recorded)". W07's requirement is met in the strongest available form: the unknown case is representable and propagated rather than defaulted.
  • Exclusions are first-class values with reasons. COMPLETED/CANCELLED are filtered by a first-decisive-rule using domain::lifecycle::TaskStatus tokens (so the status vocabulary has one home), and multiple strength categories are excluded and surfaced as invalid_policy_strength warnings rather than silently picking one — exactly domain-model.md's "invalid-policy observation requiring correction".
  • The career floor reads recorded outcomes, and the tests police what must not count: an allocation row does not count, an unknown-time report does not count, and a previous-day row does not count. That is the difference between "an explicit qualifying outcome" and "a block was scheduled", which is the distinction the policy exists to enforce.
  • Capacity honours the configuration (Monday 09:00–12:00 ⇒ 180m) and downgrades to unknown when calendar coverage is incomplete, so an incomplete read can never underpin an availability claim.

One gap recorded, not waved through. The blocker set is an input (FilterContext.active_blockers) and nothing in the tree derives it, so criterion 4's matching rule is implemented and tested while the source does not yet exist end-to-end. Your PR discloses this plainly ("this issue does not derive it... #24 supplies the set (or an empty one)"), which is the right call. I am filing it as a follow-up so the capability is closed rather than left implicit: recording a blocker as an explicit structured linked-context fact and deriving the set from it.

Also noted and accepted: journal corrections (#29) are not folded into day_outcomes yet, and the 40% unscheduled reserve belongs to proposal generation (#25–#27) — both correctly out of scope.

Merging.

## Coordinator review — APPROVED Verified `c77ef83` myself: `cargo test` **499 passed / 0 failed** (19 new policy-filter tests), `cargo clippy --all-targets -- -D warnings` 0 errors, `cargo fmt --check` clean. What I checked beyond the summary: - **"Unknown is not a value" is the module's organising principle, not an afterthought.** Unknown attributes are enumerated in `EligibleCandidate::unknowns` so a later layer cannot quietly consume a missing fact as if it were present, unknown strength is never read as active, no configured working windows is `WindowCapacity::Unknown` (nothing excluded for exceeding it), and an unobserved day is `CareerFloor::Unknown` — distinct from `Unmet`, with `write!` rendering it as "unknown (no outcome observation recorded)". W07's requirement is met in the strongest available form: the unknown case is representable and propagated rather than defaulted. - **Exclusions are first-class values with reasons.** `COMPLETED`/`CANCELLED` are filtered by a first-decisive-rule using `domain::lifecycle::TaskStatus` tokens (so the status vocabulary has one home), and multiple strength categories are excluded **and** surfaced as `invalid_policy_strength` warnings rather than silently picking one — exactly `domain-model.md`'s "invalid-policy observation requiring correction". - **The career floor reads recorded outcomes, and the tests police what must *not* count**: an allocation row does not count, an unknown-time report does not count, and a previous-day row does not count. That is the difference between "an explicit qualifying outcome" and "a block was scheduled", which is the distinction the policy exists to enforce. - Capacity honours the configuration (Monday 09:00–12:00 ⇒ 180m) and downgrades to unknown when calendar coverage is incomplete, so an incomplete read can never underpin an availability claim. **One gap recorded, not waved through.** The blocker set is an input (`FilterContext.active_blockers`) and nothing in the tree derives it, so criterion 4's *matching* rule is implemented and tested while the *source* does not yet exist end-to-end. Your PR discloses this plainly ("this issue does not derive it... #24 supplies the set (or an empty one)"), which is the right call. I am filing it as a follow-up so the capability is closed rather than left implicit: recording a blocker as an explicit structured linked-context fact and deriving the set from it. Also noted and accepted: journal corrections (#29) are not folded into `day_outcomes` yet, and the 40% unscheduled reserve belongs to proposal generation (#25–#27) — both correctly out of scope. Merging.
yuris merged commit a02c6ca590 into main 2026-09-23 00:16:43 +00:00
yuris changed title from feat(domain): next-task policy filter and candidate window slicing (#22) to feat(domain): next-task policy filter and candidate window slicing 2026-09-23 05:12:03 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
yuris/life-service!62
No description provided.