feat(domain): next-task policy filter and candidate window slicing #62
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feat/issue-22-filter"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Motivation
life nextmust not hand task selection to a model first.docs/technical/jev.md#next-task-policysteps 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 thelife next/POST /v1/nextroute (#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.rssupplies theApiItemsnapshot; this module never reads a projection and never writes), following the decision-half pattern already used bysrc/domain/resolve.rs. Evaluation order, first decisive rule wins:COMPLETED/CANCELLEDare strict exclusions (case-insensitive, trimmed, compared againstdomain::lifecycle::TaskStatus's four tokens). A missing externalSTATUSis 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.[planning] collection_ids, when non-empty, bounds candidacy; empty means every configured collection.life-activeis a candidate.life-queued/life-somedayare excluded, and a task with no strength category isstrength_unknown(unknown strength is not active). A task carrying several strength categories is excluded and surfaced asinvalid_policy_strengthinwarnings— an invalid-policy observation requiring correction, never a silent pick of one category.planned_start > now(instant) or a date-only start later than today is excluded; an instant exactly atnowis not future.item.id(item_id_for) againstFilterContext::active_blockers. A blocker is an explicit user fact; the UID or another item's id never matches, and nothing here infers one.estimated_minutesabove the known window is excluded (window_exceeded); a known estimate within it is stated as fitting. An unknown estimate is retained, listed inunknowns: ["estimated_minutes"]and never described as fitting (scenario W07).highis out atlowandmedium); unknown energy is retained and labelledunknowns: ["energy"], never assumed low.area-<label>restriction.area-careertask under an unmet weekday floor is markedcareer_floor_priority: the eligibility signal for ranking, not the order.NextTaskQuery.window_minutes(the caller's own statement) wins; otherwise the deployment's configuredworking_windowsfor today are summed when calendar coverage is complete; otherwiseWindowCapacity::Unknown. Unknown capacity excludes nothing and claims no fit, and emits acapacity_unknownwarning — 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 onafter_json.qualifies_career_floororoutcome = 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.CareerFlooris tri-state.from_day_outcomes(observed, qualifying, floor)yieldsMet,Unmet(outcomes observed, fewer qualifying than[planning] weekday_career_floor) orUnknown(nothing observed — a gap in the record, not a failure), plusNotApplicableon weekends. The issue sketch'scareer_floor_met: boolcould not express this distinction, whichdocs/technical/workflows.md#weekly-analyticsrequires.src/domain/mod.rs—EnergygainsOrdandparse/as_str, so a task's user-asserted requirement and the caller's available energy compare on one vocabulary, and an unrecognized token isNone(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 carriesitem: ApiItem(livedeadline,project_id,categories,strength,etag…),factual_reasons: Vec<String>,unknowns: Vec<UnknownAttribute>(estimated_minutes/energy/window/status),is_career_task: boolandcareer_floor_priority: bool(the unmet-floor tie-break signal).excluded: Vec<FilterDiagnostic> { item_id, title, reason: ExclusionReason }— one typed reason per dropped task, withreason.code()for machines andDisplay/message()for people.warnings: Vec<WarningItem>—invalid_policy_strength,capacity_unknown.capacity: WindowCapacityandcareer_floor: CareerFloor— echoed so a caller cannot re-derive them differently.#23 computes deadline urgency itself from
candidate.item.deadline; the filter deliberately produces noDeadlineUrgencyTier, since tiering is part of the ordering this issue leaves to #23, andunknowns/capacity/warningsare 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 intests/policy_filter_tests.rs).cargo clippy --all-targets -- -D warnings— clean.cargo fmtthencargo fmt --check— clean.tests/policy_filter_tests.rs:life-someday,life-queued, missing strength, multiple strengths (excluded and warned), futureplanned_start(instant and date-only), active blocker by canonical composite id, 120m against a 90m window,highenergy againstlowandmedium, area mismatch, out-of-scope collection;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
FilterContext::active_blockersholds canonicalitm_…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.weekday_career_floor(including0, 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.day_outcomesreads the recorded outcome facts as written.Related Issues
Closes #22
Coordinator review — APPROVED
Verified
c77ef83myself:cargo test499 passed / 0 failed (19 new policy-filter tests),cargo clippy --all-targets -- -D warnings0 errors,cargo fmt --checkclean.What I checked beyond the summary:
EligibleCandidate::unknownsso 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 isWindowCapacity::Unknown(nothing excluded for exceeding it), and an unobserved day isCareerFloor::Unknown— distinct fromUnmet, withwrite!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.COMPLETED/CANCELLEDare filtered by a first-decisive-rule usingdomain::lifecycle::TaskStatustokens (so the status vocabulary has one home), and multiple strength categories are excluded and surfaced asinvalid_policy_strengthwarnings rather than silently picking one — exactlydomain-model.md's "invalid-policy observation requiring correction".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_outcomesyet, and the 40% unscheduled reserve belongs to proposal generation (#25–#27) — both correctly out of scope.Merging.
feat(domain): next-task policy filter and candidate window slicing (#22)to feat(domain): next-task policy filter and candidate window slicing