feat(domain): multi-criteria lexicographic next-task ranking #64

Merged
yuris merged 1 commit from feat/issue-23-ranking into main 2026-09-23 00:33:30 +00:00
Owner

Motivation

Step 3 of the next-task policy (docs/technical/jev.md#next-task-policy) fixes an explicit lexicographic order over the eligible candidates that issue #22 produces, and step 4 fixes the shape of what is returned; scenario W07 requires at most three choices in that documented order with factual and semantic reasons kept apart. Nothing implemented it yet: #22 deliberately stops at "eligible, with facts and unknowns" and states that it never orders. This change adds that engine and nothing above it — no route, no CLI, no presentation, which #24 owns.

Changes

  • src/domain/policy/next_task_ranker.rs (new) — the ranking engine over #22's EligibleCandidates:
    • Tier 1, deadline urgency — overdue (earlier first), then a deadline inside the known window (earlier first), then a known later deadline (earlier first), then no deadline. An overdue task outranks any semantic score. A date-only deadline is compared at the end of its local day and is reported as the date it states: the two are never converted into each other, so no midnight appointment is invented, and with no known window a future deadline is not claimed to fall inside one.
    • Tier 2, the unmet weekday career floor — the career_floor_priority signal #22 already decided (true only when the floor is Unmet). It is applied after the deadline, so it only reorders candidates the deadline already tied; Unknown is never read as unmet.
    • Tier 3, judgment — alignment.v1, then consequence.v1, then readiness.v1, each descending, from JevService::candidate_advice.
    • Tier 4, the stable item id — item.id.cmp(&other.id).
    • compare_candidates is that order as a public function, so the order the sort uses is exactly the order that is tested for antisymmetry, transitivity and permutation invariance.
    • Reasons are segregated: #22's eligibility facts plus the deadline fact the ranker adds are factual_reasons; the three answers are semantic_reasons. No score can enter a factual reason, and no score is read as a deadline, duration, energy, status or window fit.
    • Disclosed scoring bound: MAX_SCORED_CANDIDATES = 20 (= MAX_CHOICE_CANDIDATES, the reviewed pool cap the resolution path already applies and discloses) because every judged candidate costs three dispatches. When a larger set is considered, the result carries candidate_narrowed naming how many were judged and that the rest are ordered by the deadline and career facts plus the item id. No candidate is dropped: the whole order is returned.
    • Degradation: any missing family, a disabled service (which dispatches nothing), an unreachable one, or a refused answer makes the whole order Deterministic — the scores are discarded rather than deciding part of the order — with judgment_unavailable / judgment_invalid attached and every candidate still ranked.
    • Dispatches are concurrent (one task per judged candidate) and the pass makes no calendar or journal access at all.
  • src/domain/policy/mod.rs — registers and re-exports the module; its module doc now says the ranking lives beside the filter instead of being future work.

The typed surface #24 will consume

let ranking = rank_candidates(filter.candidates, &query, &context, &tz, &jev).await?; // Err: CalendarError
ranking.top()              // &[RankedCandidate] — at most TOP_CANDIDATES (3), in policy order
ranking.beyond_top_three() // the considered tail, so a truncation is never silent
ranking.basis()            // RankingBasis::Semantic | Deterministic (never present the latter as semantic)
ranking.judgment()         // Option<&JudgmentProvenance> — { model: &str, questions: [&str; 3] }
ranking.warnings()         // &[WarningItem]: candidate_narrowed, judgment_unavailable | judgment_invalid

round.item()                          // &ApiItem (id, title, categories, deadline, …)
round.factual_reasons()               // iterator over the factual reasons, no score among them
round.semantic_reasons               // Vec<String> family lines, empty when deterministic
round.unknowns()                      // &[UnknownAttribute] exactly as #22 reported them
round.urgency.tier / .at / .reason    // the tier token, the compared instant, the sentence
round.candidate.career_floor_priority // the Tier-2 signal

POST /v1/next maps these onto the documented candidates entries (item_id, factual_reasons, semantic_reasons, unknowns, judgment provenance), merges #22's filter warnings and exclusion diagnostics into the envelope, and maps the Err (a date-only deadline whose local midnight this zone cannot resolve) to calendar_unsupported, as the tasks and agenda reads already do.

Verification

  • nix develop --command cargo test — 514 tests, every suite green (28 suites; 499 before this change, all still passing).
  • nix develop --command cargo fmt --check and nix develop --command cargo clippy --all-targets -- -D warnings — clean.
  • New tests/next_ranking_tests.rs (15 tests), driving the real path end to end: live tasks come from a mock CalDAV server, the candidate set from the #22 filter itself, and the semantic families from a mock System One that answers exactly what the adapter validates.
    • Each tier deciding alone: overdue versus no deadline (with semantics strongly favouring the undated task); two deadlines inside the window, then a later known one; the unmet career floor breaking an urgency tie (and Met/Unknown/NotApplicable leaving the item id to decide); alignment, then consequence, then readiness separating identical facts; identical answers falling through to the item id.
    • A date-only deadline weighing at the end of its local day, and a known deadline never claimed inside an unknown window.
    • The comparator is total and reproducible: antisymmetry over every pair, transitivity over every triple, the same order across runs, and the same order from a reversed input.
    • Truncation: exactly three returned with the considered tail observable; a 21-candidate set proving the bound bites (60 dispatches, candidate_narrowed naming it, 21 candidates still returned, the unjudged one left unjudged and disclosed).
    • Reason segregation with unknowns propagated (estimated_minutes, energy), and the factual reasons identical with and without a judgment.
    • Degradation: unreachable provider (judgment_unavailable), disabled provider (dispatches nothing), drifted model refused (judgment_invalid) — each still ranking every candidate deterministically.
    • Zero writes on the live path: journal row counts unchanged across all six tables, no PUT/POST/DELETE against the mock CalDAV (only its reads), with a semantic ranking actually produced.

Closes #23

## Motivation Step 3 of the next-task policy (`docs/technical/jev.md#next-task-policy`) fixes an explicit lexicographic order over the eligible candidates that issue #22 produces, and step 4 fixes the shape of what is returned; scenario W07 requires at most three choices in that documented order with factual and semantic reasons kept apart. Nothing implemented it yet: #22 deliberately stops at "eligible, with facts and unknowns" and states that it never orders. This change adds that engine and nothing above it — no route, no CLI, no presentation, which #24 owns. ## Changes - `src/domain/policy/next_task_ranker.rs` (new) — the ranking engine over #22's `EligibleCandidate`s: - **Tier 1, deadline urgency** — `overdue` (earlier first), then a deadline inside the known window (earlier first), then a known later deadline (earlier first), then no deadline. An overdue task outranks any semantic score. A date-only deadline is *compared* at the end of its local day and is *reported* as the date it states: the two are never converted into each other, so no midnight appointment is invented, and with no known window a future deadline is not claimed to fall inside one. - **Tier 2, the unmet weekday career floor** — the `career_floor_priority` signal #22 already decided (`true` only when the floor is `Unmet`). It is applied after the deadline, so it only reorders candidates the deadline already tied; `Unknown` is never read as unmet. - **Tier 3, judgment** — `alignment.v1`, then `consequence.v1`, then `readiness.v1`, each descending, from `JevService::candidate_advice`. - **Tier 4, the stable item id** — `item.id.cmp(&other.id)`. - `compare_candidates` is that order as a public function, so the order the sort uses is exactly the order that is tested for antisymmetry, transitivity and permutation invariance. - Reasons are segregated: #22's eligibility facts plus the deadline fact the ranker adds are `factual_reasons`; the three answers are `semantic_reasons`. No score can enter a factual reason, and no score is read as a deadline, duration, energy, status or window fit. - **Disclosed scoring bound**: `MAX_SCORED_CANDIDATES = 20` (= `MAX_CHOICE_CANDIDATES`, the reviewed pool cap the resolution path already applies and discloses) because every judged candidate costs three dispatches. When a larger set is considered, the result carries `candidate_narrowed` naming how many were judged and that the rest are ordered by the deadline and career facts plus the item id. No candidate is dropped: the whole order is returned. - **Degradation**: any missing family, a disabled service (which dispatches nothing), an unreachable one, or a refused answer makes the whole order `Deterministic` — the scores are discarded rather than deciding part of the order — with `judgment_unavailable` / `judgment_invalid` attached and every candidate still ranked. - Dispatches are concurrent (one task per judged candidate) and the pass makes no calendar or journal access at all. - `src/domain/policy/mod.rs` — registers and re-exports the module; its module doc now says the ranking lives beside the filter instead of being future work. ### The typed surface #24 will consume ```rust let ranking = rank_candidates(filter.candidates, &query, &context, &tz, &jev).await?; // Err: CalendarError ranking.top() // &[RankedCandidate] — at most TOP_CANDIDATES (3), in policy order ranking.beyond_top_three() // the considered tail, so a truncation is never silent ranking.basis() // RankingBasis::Semantic | Deterministic (never present the latter as semantic) ranking.judgment() // Option<&JudgmentProvenance> — { model: &str, questions: [&str; 3] } ranking.warnings() // &[WarningItem]: candidate_narrowed, judgment_unavailable | judgment_invalid round.item() // &ApiItem (id, title, categories, deadline, …) round.factual_reasons() // iterator over the factual reasons, no score among them round.semantic_reasons // Vec<String> family lines, empty when deterministic round.unknowns() // &[UnknownAttribute] exactly as #22 reported them round.urgency.tier / .at / .reason // the tier token, the compared instant, the sentence round.candidate.career_floor_priority // the Tier-2 signal ``` `POST /v1/next` maps these onto the documented `candidates` entries (`item_id`, `factual_reasons`, `semantic_reasons`, `unknowns`, judgment provenance), merges #22's filter warnings and exclusion diagnostics into the envelope, and maps the `Err` (a date-only deadline whose local midnight this zone cannot resolve) to `calendar_unsupported`, as the tasks and agenda reads already do. ## Verification - `nix develop --command cargo test` — 514 tests, every suite green (28 suites; 499 before this change, all still passing). - `nix develop --command cargo fmt --check` and `nix develop --command cargo clippy --all-targets -- -D warnings` — clean. - New `tests/next_ranking_tests.rs` (15 tests), driving the real path end to end: live tasks come from a mock CalDAV server, the candidate set from the #22 filter itself, and the semantic families from a mock System One that answers exactly what the adapter validates. - Each tier deciding alone: overdue versus no deadline (with semantics strongly favouring the undated task); two deadlines inside the window, then a later known one; the unmet career floor breaking an urgency tie (and `Met`/`Unknown`/`NotApplicable` leaving the item id to decide); alignment, then consequence, then readiness separating identical facts; identical answers falling through to the item id. - A date-only deadline weighing at the end of its local day, and a known deadline never claimed inside an unknown window. - The comparator is total and reproducible: antisymmetry over every pair, transitivity over every triple, the same order across runs, and the same order from a reversed input. - Truncation: exactly three returned with the considered tail observable; a 21-candidate set proving the bound bites (60 dispatches, `candidate_narrowed` naming it, 21 candidates still returned, the unjudged one left unjudged and disclosed). - Reason segregation with `unknowns` propagated (`estimated_minutes`, `energy`), and the factual reasons identical with and without a judgment. - Degradation: unreachable provider (`judgment_unavailable`), disabled provider (dispatches nothing), drifted model refused (`judgment_invalid`) — each still ranking every candidate deterministically. - Zero writes on the live path: journal row counts unchanged across all six tables, no `PUT`/`POST`/`DELETE` against the mock CalDAV (only its reads), with a semantic ranking actually produced. ## Related Issues Closes #23
yuris force-pushed feat/issue-23-ranking from 7423467ae9 to ae9abf0f57 2026-09-23 00:32:17 +00:00 Compare
Author
Owner

Coordinator review — APPROVED

Verified ae9abf0 myself: cargo test 514 passed / 0 failed (15 new ranking tests), cargo clippy --all-targets -- -D warnings 0 errors, cargo fmt --check clean.

What I checked beyond the summary:

  • The comparator reads exactly like jev.md step 3 — tier → deadline instant → career floor (descending) → alignment/consequence/readiness (descending) → item id — and DeadlineUrgencyTier derives Ord in the documented sequence, so the tier order cannot drift out of the spec by a later reordering. That is the right way to make a policy order hard to break.
  • It is proven a total order, not assumed. Antisymmetry over every pair, transitivity over every triple, identical results across runs and from a reversed input. A comparator that only looks lexicographic is the classic source of nondeterministic ordering, and this closes it.
  • Tier one dominates semantics, as required: a perfectly-scored undated task ranks behind an overdue task scored 0/0/0, with the factual reason naming the overdue interval.
  • The career-floor tier uses #22's signal and never reinterprets Unknown as unmet, and it only reorders already-tied candidates — a career task with a later deadline still sits behind a non-career earlier deadline.
  • Truncation is bounded and disclosed: MAX_SCORED_CANDIDATES = 20 applied to the factual order (so the choice is deterministic), with candidate_narrowed — the identifier #20 already established — naming the bound, and all 21 candidates still returned with the unjudged one marked unjudged. Nothing is silently dropped. The 21-candidate test asserting exactly 60 dispatches (20 × 3 families) is precise evidence rather than a vibe.
  • Degradation discards the whole semantic pass rather than blending a partial ranking: any missing family, a disabled service, a dead provider or a refused answer yields RankingBasis::Deterministic with the scores dropped and the right warning — which is stronger than "rank what we have", and matches "never present a deterministic order as a semantic ranking".
  • Scores cannot become facts: the factual reason lists are asserted byte-identical with and without a judgment, in both the unit and the live test.

Merging. #24 now consumes rank_candidates(...) -> NextTaskRanking.

## Coordinator review — APPROVED Verified `ae9abf0` myself: `cargo test` **514 passed / 0 failed** (15 new ranking tests), `cargo clippy --all-targets -- -D warnings` 0 errors, `cargo fmt --check` clean. What I checked beyond the summary: - **The comparator reads exactly like `jev.md` step 3** — `tier → deadline instant → career floor (descending) → alignment/consequence/readiness (descending) → item id` — and `DeadlineUrgencyTier` derives `Ord` in the documented sequence, so the tier order cannot drift out of the spec by a later reordering. That is the right way to make a policy order hard to break. - **It is proven a total order, not assumed.** Antisymmetry over every pair, transitivity over every triple, identical results across runs and from a reversed input. A comparator that only *looks* lexicographic is the classic source of nondeterministic ordering, and this closes it. - **Tier one dominates semantics, as required:** a perfectly-scored undated task ranks behind an overdue task scored 0/0/0, with the factual reason naming the overdue interval. - **The career-floor tier uses `#22`'s signal and never reinterprets `Unknown` as unmet**, and it only reorders already-tied candidates — a career task with a later deadline still sits behind a non-career earlier deadline. - **Truncation is bounded and *disclosed*:** `MAX_SCORED_CANDIDATES = 20` applied to the *factual* order (so the choice is deterministic), with `candidate_narrowed` — the identifier `#20` already established — naming the bound, and all 21 candidates still returned with the unjudged one marked unjudged. Nothing is silently dropped. The 21-candidate test asserting exactly 60 dispatches (20 × 3 families) is precise evidence rather than a vibe. - **Degradation discards the whole semantic pass** rather than blending a partial ranking: any missing family, a disabled service, a dead provider or a refused answer yields `RankingBasis::Deterministic` with the scores dropped and the right warning — which is stronger than "rank what we have", and matches "never present a deterministic order as a semantic ranking". - **Scores cannot become facts:** the factual reason lists are asserted byte-identical with and without a judgment, in both the unit and the live test. Merging. `#24` now consumes `rank_candidates(...) -> NextTaskRanking`.
yuris merged commit 66ce8bab01 into main 2026-09-23 00:33:30 +00:00
yuris changed title from feat(domain): multi-criteria lexicographic next-task ranking (#23) to feat(domain): multi-criteria lexicographic next-task ranking 2026-09-23 05:12:02 +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!64
No description provided.