Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -530,6 +530,8 @@ docs/architecture/session-memory/*
docs/architecture/plan-integration/*
!docs/architecture/plan-integration/council/
!docs/architecture/plan-integration/receipts/
# Un-ignored 2026-09-18 — item 6's written proposals (D-D, D-E), Markdown only.
!docs/architecture/plan-integration/proposals/
!docs/architecture/plan-integration/*.architecture.json
!docs/architecture/plan-integration/*.md

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
# Proposal: a context-derived plan bias (D-D)

**Status:** written proposal, no code (plan-integration follow-on item 6, T6.4). Becomes a
`ready-for-agent` issue at the push step (HANDOFF §3 item 7, step 4). Written 2026-09-18 against
`main` at `a5b9f903`; every code fact below was read from that tree.

## The owner's decision, verbatim

> **D-D** | **F2 → open a ticket, don't park:** a context-derived plan bias (prerequisite edges from the
> concept store via `get_concept_context`, milestone order; per-item energy demand from struggle state)
> — deterministic and rubric-testable. Not an LLM tie-break (unauditable; defeats D-16). Scenario 1's
> "the logical step before" was the first evidence.
>
> — `HANDOFF-2026-09-16.md` §2, owner, 2026-09-16

The evidence it names: rubric row 1 (`receipts/now-rubric-2026-09-16.md`), owner's verdict **yes** with
the line *"window function is the logical step before decorating it"* — a prerequisite-order argument the
engine did not make. The engine ranked the plan-matching due item first because it carried the plan
bias; the owner ranked it first because of what depends on it. Same answer, different reason, and the
reason is the one that generalises.

## What the engine does today (`studyloop/learning/decision.py`)

- **Rule 5, one constant.** `PLAN_RELATED_BIAS = 12`, added to a candidate's score when it carries a
`plan_ref` or its concept/topic keys intersect a matchable plan's keys. The constant is sized to decide
a near-tie *inside one urgency class* and to lose to a clearly more-urgent unrelated candidate
(a struggling repair at +35, an overdue review whose base is `100 + min(days_ago, 30)`): a bias,
never a filter. Review 3 F2 asked whether `now` should grow explicit urgency classes; the council kept
the bias and pinned the constant to today's bands.
- **Rule 3, one floor per plan.** `ENERGY_CAPABILITY = {"low": 3, "medium": 6, "high": 10}` is compared
with the plan's `energy_floor` (1–10); below the floor *new* milestone work is deferred while
plan-related due recall and struggle repair stay eligible, "because repair is cheaper than encoding".
Rubric row 3 (owner: **no**) is the counter-example — a live-struggle repair on a low-energy day —
and is item 5's subject, not this proposal's.
- **Rule 6, milestone order by position.** A synthesised next-milestone candidate is the plan's first
open milestone, base `MILESTONE_BASE_SCORE = 48` plus a target-urgency bonus. Milestone order is the
document's order; nothing reads what a milestone's concepts require.
- **What the concept store already knows.** `learning/mastery.py::list_dependencies(topic)` returns
edges with `source_concept`, `target_concept`, `relation_type`; `weak_links_for_topic` already
consumes `relation_type == "prerequisite"` to name struggling concepts that block downstream ones.
`get_concept_context` (MCP) returns `mastery_graph_json`'s `edges` for a topic, with the same fields,
capped at 32 KiB and explicit that omitted edges cannot support a claim that no alternative exists.

So the ingredients exist and are already read for another purpose; what is missing is a rule that turns
them into a ranking signal for `now`.

## The proposal

Replace the single constant with a **derived bias** computed from three deterministic inputs, each a
rule with its own rubric row. The total stays bounded so the "bias, never a filter" invariant holds:
a clearly more-urgent unrelated candidate must still win.

| Rule | Input | Signal | Bound |
|---|---|---|---|
| 5a — plan relevance (today's rule 5) | plan refs / key intersection | the existing `+12` | as today |
| 5b — prerequisite order | `list_dependencies(topic)` edges with `relation_type == "prerequisite"` | a candidate whose concept is a **prerequisite of an open milestone's concept** in a matchable plan gains a small bonus; a candidate whose concept **depends on** a concept the learner is `struggling` with loses the same amount ("the logical step before" comes first) | ± a value below the urgency-class gap, pinned like `PLAN_RELATED_BIAS` |
| 5c — per-item energy demand | struggle state (`confidence`, `last_teachback_score`) and action type | a **demand** per candidate (repair of a live struggle is high demand; a recall of a `learning` concept is low) compared with `ENERGY_CAPABILITY[energy]`, so rule 3's floor stops being the plan's only energy fact | shared with item 5 (D-F); this proposal takes item 5's definition when it lands rather than inventing a second |

Design constraints, from the decision:

1. **Deterministic.** Every input is a stored fact (an edge, a confidence, a score); the bias for a
fixed world is a pure function. No model call anywhere in ranking — an LLM tie-break is
unauditable and defeats the D-16 rubric, which asks a human "would you do the primary?" and needs
the answer to follow from stated rules.
2. **Rubric-testable.** Each rule gets a D-16 rubric row with a planted world and an expected primary,
scored by the owner before it ships: row 1 re-run under 5b should still be **yes** and now for the
engine's reason; a new row plants a struggling prerequisite and expects the dependent milestone
*not* to be primary.
3. **Still a bias.** The golden `now_plan_no_active.json` stays byte-identical (no plan → no bias); the
existing rule-5 pins (`test_matching_due_concept_outranks_unrelated_same_urgency`,
`test_unrelated_more_urgent_due_outranks_new_milestone`, `test_weak_due_still_beats_overdue_synthesised_milestone`)
stay green with the derived value in place of the constant.
4. **Partial knowledge is not knowledge.** `get_concept_context` is explicit that omitted edges cannot
support "no alternative exists"; 5b must treat an absent edge as *no signal*, never as "not a
prerequisite".
5. **Read once, through the seam.** Edges are read in `_PlanContext.build` beside the plan read, so the
rule-1 pin (no per-consumer plan reads, no checkpoint-history reads) holds; the concept store is a
second read, budgeted and measured on the live database before it ships (the item-4 preview read was
measured at ~320 ms on an 877 MB `sessions.db` and recorded; this must be too).

## Out of scope

- Item 5 (D-F) owns the energy-demand *definition* and the body-doubling floor; this proposal consumes
it and must not define a second one.
- No change to what a plan document stores. Prerequisite knowledge lives in the concept store; the
plan keeps naming concepts per milestone.
- No filter, no reordering of urgency classes (review 3 F2 stands).

## Acceptance (for the issue)

- `PLAN_RELATED_BIAS` is replaced by a derived value with a pinned upper bound; the three rule-5 pins
above and the golden byte-identity stay green unchanged.
- RED tests, one per rule: a prerequisite-of-open-milestone candidate outranks a same-class sibling;
a candidate dependent on a struggling concept loses to that concept's repair; an absent edge changes
no score.
- A measured read cost on a real `sessions.db`, recorded beside the constant.
- Rubric rows added and scored by the owner (D-16), row 1 re-run.
- Docs: `docs/study-plans.md` "Plan-aware now" describes the derived bias in the eligibility terms the
contract test pins.

## References

`HANDOFF-2026-09-16.md` §2 D-D; `receipts/now-rubric-2026-09-16.md` rows 1–3; `council/review-3-arbitration-2026-09-16.md`
F2; `studyloop/learning/decision.py` (`PLAN_RELATED_BIAS`, `ENERGY_CAPABILITY`, `MILESTONE_BASE_SCORE`,
rule 5 application site); `studyloop/learning/mastery.py` (`list_dependencies`, `weak_links_for_topic`,
`agent_concept_context`).
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
# Proposal: an age-aware nudge and a retire/snooze door for an overdue item (D-E)

**Status:** written proposal, no code (plan-integration follow-on item 6, T6.4). Becomes a
`ready-for-agent` issue at the push step (HANDOFF §3 item 7, step 4). Written 2026-09-18 against
`main` at `a5b9f903`; every code fact below was read from that tree.

## The owner's decision, verbatim

> **D-E** | Scenario 2 note: an overdue item **unrelated** to the plan must not sit as an alternate
> indefinitely. Fact: due score already grows `+1/day` (cap +30), so it overtakes the +12 bias in
> ~2 weeks; missing are an **age-aware nudge line** and a **retire/snooze** action for a due card (only
> backlog topics can be `resolved` today).
>
> — `HANDOFF-2026-09-16.md` §2, owner, 2026-09-16

The evidence it names: rubric row 2 (`receipts/now-rubric-2026-09-16.md`), owner's verdict **yes** —
clear the overdue review first — with the note: *"an overdue item unrelated to the plan must not sit as
an alternate indefinitely — propose it explicitly (age-aware nudge) or let the learner retire it."*

## What the engine and the store do today

- **A due item is a `study_progress` row whose `last_seen` is old enough.** `history/progress.py`
computes `days_ago` from `last_seen` and marks the row due when it reaches an interval in
`REVIEW_INTERVALS` — 1, 3, 7, 14, 30 days, each with a review type ("5-min recall quiz" …
"Teach-back session"); a `struggling` row is always due as "Guided repair + tiny practice". There is
**no `next_review` column** and no state that says "not due": a `mastered` row keeps coming back on
the same intervals, and the only thing that moves `last_seen` is studying it again
(`record_progress`). A row therefore stays due — and stays a `now` candidate — until it is studied
or deleted.
Comment on lines +27 to +29
- **The score arithmetic the decision cites is right.** `learning/decision.py` scores a due candidate
`100 + min(days_ago, 30)` (+35 if `struggling`, +15 if `learning`). A plan-related candidate adds
`PLAN_RELATED_BIAS = 12` (rule 5). So an unrelated due item at *d* days beats a plan-related one at
*d′* days once `d > d′ + 12`: roughly two weeks of sitting as an alternate, then it wins the primary
on age alone — silently. The learner sees it move up; nothing tells them why, and nothing offers a
way to say "I have moved on from this".
- **`resolved` exists only for parked topics.** `record_topic_progress(confidence="resolved")` (MCP)
calls `parking.resolve_parked_topic(topic_id)` — a *backlog* row, not a `study_progress` row. The
Today card and CLI `now` have no control on a due item at all.

## The proposal

Two additions, both small, both on the existing candidate — no new ranking rule.

### 1. An age-aware nudge line

When an eligible due candidate is **unrelated to every matchable plan** and has been due long enough
to have overtaken the plan bias — i.e. `days_ago ≥ threshold`, with the threshold **derived from the
constants**, not a second number: the smallest `days_ago` at which `min(days_ago, 30) ≥ PLAN_RELATED_BIAS`
plus the plan-related candidate's own age — the candidate carries a `nudge` line that says so in plain
Comment on lines +46 to +49
words: *"Overdue 16 days and unrelated to your plan — do it, or retire it: `studyloop review retire
<topic> <concept>`."* Rendered where the completion review's evidence lines are rendered today (CLI
`now` beneath the action, Today card beside it, MCP payload as an additive key). The line is a fact
about age, not a proposal to rank differently.

### 2. A retire/snooze door for a due item

A `study_progress` row gains one of two learner-issued states, through the seam every surface uses:

| Door | Meaning | Mechanism | Undo |
|---|---|---|---|
| **retire** | "I am done with this concept for now" | a `retired_at` timestamp on the row (or a `confidence` value the due predicate excludes — decided at RED with the migration); the row is no longer due, keeps its history, and disappears from every consumer of `spaced_repetition_due` — `now` (CLI, Today, MCP `get_next_action`), recap, `studyloop review`, and the plan evaluation's due count | studying it again (`record_progress`) clears the state |
| **snooze** | "not this week" | a `snoozed_until` date; the row is not due before it and returns to the normal intervals after | expiry, or an explicit un-snooze |

Exposed the same way the plan lifecycle is: a CLI verb (`studyloop review retire|snooze`), a Today-card
control on the due item, and one MCP tool beside `record_study_progress` — the mirror of
`record_topic_progress(confidence="resolved")` for cards that the decision asks for. The store writes
one row; retire is never inferred from age (the decision says *let the learner* retire it).
Comment on lines +61 to +67

Design constraints:

1. **Learner-issued only.** Nothing retires or snoozes on the learner's behalf — the RSD-safe framing
the project keeps: the nudge proposes, the learner decides (same shape as item 4's consensual
close).
2. **History kept.** A retired row is excluded from the due set, not deleted; `get_study_history` and
the plan evaluation's `unverified_milestones` still see it.
3. **Plan-aware, not plan-bound.** The nudge fires only for items unrelated to every matchable plan;
a plan-related overdue item is the plan's business (item 4's closing review already counts it).
4. **Golden unchanged.** The no-plan golden holds a `source=starter` primary with no due items, so it
is byte-identical by construction; the nudge is an additive key on a due candidate.
5. **The threshold is derived, not a new constant** — so when D-D replaces `PLAN_RELATED_BIAS` with a
derived bias, the nudge moves with it.

## Out of scope

- Any change to `REVIEW_INTERVALS` or to how `days_ago` is computed.
- Reranking. The overtaking-on-age behaviour is correct (row 2: clear the overdue review first); what
is missing is the *explanation* and the *exit*, not a different order.
- Flashcard/quiz `card_reviews` — a different store with its own scheduler (a simplified SM-2 in
`review_db.py`, read by `get_due_cards` through `review_service.due_cards`); a retire door for those
is a separate ticket if wanted.

## Acceptance (for the issue)

- RED: a due unrelated item past the derived threshold carries the nudge line on CLI, Today and MCP;
a plan-related one does not; one below the threshold does not.
- RED: `retire` removes the row from the due set and from `now` candidates, keeps its history row,
and `record_progress` on the same concept clears it; `snooze` excludes it until the date and not
after.
- The migration is in `agent_session_tools.migrations` and the clean-rebuild closure classifies the
new column/state (the 2026-09-12 rebuild caught two tables the migrations did not create; do not
repeat that).
- Golden `now_plan_no_active.json` byte-identical; `test_docs_plan_integration_contract.py` green
with the doc sentence added to `docs/study-plans.md` "Plan-aware now" and `docs/cli-reference.md`.
- A D-16 rubric row: row 2's world advanced 16 days, the owner asked whether the nudge line is one
they would act on.

## References

`HANDOFF-2026-09-16.md` §2 D-E; `receipts/now-rubric-2026-09-16.md` row 2; `studyloop/history/progress.py`
(`REVIEW_INTERVALS`, `_review_type_for`, `_progress_review_due`, `spaced_repetition_due`,
`record_progress`); `studyloop/learning/decision.py` (due scoring `100 + min(days_ago, 30)`,
`PLAN_RELATED_BIAS`); `studyloop/mcp/tools.py` (`record_topic_progress`, `resolve_parked_topic`);
`agent_session_tools/migrations.py` (`study_progress` columns: `id, topic, concept, confidence,
first_seen, last_seen, session_count, notes, created_at, updated_at`).
Original file line number Diff line number Diff line change
@@ -1,6 +1,22 @@
# Plan integration (#7–#15) — issue close-out DRAFT · 2026-09-16

**Status: DRAFT, not posted.** Written unattended by the Phase-6 agent for the owner to post in the
**Status: POSTED 2026-09-18** (was: DRAFT, not posted). The per-issue tables below were posted as
status comments on #8–#15 after re-verification against `main` at `a5b9f903`: every `T:` node id
checked against the collected suite, every `C:` sha against `main` — seven pre-consolidation shas
(`1d071758`, `3a4f6b01`, `705ba58b`, `c16ffa35`, `daf46c81`, `dc7de0be`, `e16340ca`) were rewritten
at the history consolidation and were replaced in the comments by their `main` equivalents, found by
exact subject (`38f6f41d`, `d7f568bf`, `e50106af`, `e74c2a63`, `bfe0695c`, `3d9476d5`, `1aed49f2`);
`test_malformed_plan_browse_matches_store_list` had moved to `test_plan_application_mutations.py`.
Rows the follow-on programme had overtaken were restated in the comments, not here: #11's mission row
(revisable since item 3b), #13's "partly" (closed by item 1), #14/#15/#7's brain-dump and cancellation
gaps (closed by item 2 and the 2026-09-18 abandonment decision), #10's rubric state (rows scored except
3b). **Closed as completed:** #8, #9, #11, #12, #13, #14. **Open:** #10 (row 3b, item 5), #15 (closes
with #10), and #7 — which GitHub had auto-closed at PR #20's merge on the body's "Closes the two bugs"
phrase and which was reopened with the parent mapping so it closes after its children, as §3 item 7
of the handover intends. PR #20's body was not replaced: the PR is merged. The original draft text
follows unchanged as the record of what was known on 2026-09-16.

**Original status (2026-09-16): DRAFT, not posted.** Written unattended by the Phase-6 agent for the owner to post in the
morning, and refreshed after council review 5 (`GATE: ACCEPT`,
`council/review-5-arbitration-2026-09-16.md`), the verification receipt and the archive. Nothing here has
been sent to GitHub: no issue closed, no comment left, PR #20 untouched. Every claim below names the
Expand Down
Loading
Loading