Announce tick-inferred routines, opt-in and rate-limited #54

Closed
claude wants to merge 1 commits from overnight/proactive-proposals into overnight/split-voice-quiet
Contributor

Closes the second half of pattern inference. The digestion tick already scans every recorded action+object pair and writes a proposed_routines row (commit 67563ed on the base branch); until now that row reached nothing but the /routines page, so a pattern noticed while nobody was at the mic was only seen if he went looking.

What changed

  • cmd/mavend/tick.godetectPatterns now takes the tick's loop.State and hands each newly created proposal to a new announceProposal, which delivers it as a sev1 care nudge (proposal:<action> <object>) through loop.Gate + delivery.Dispatcher — the same path an accepted routine uses. No second delivery mechanism.
  • internal/config/config.go — new pattern_proposals block (PatternProposalConfig{Notify, Cooldown}), DefaultProposalCooldown = 24h.
  • deploy/mavend.json — the block, shipped with notify: false.
  • internal/pattern/detector.goMinEvents 3 → 4.

Restraint (Maven is not a nag, not autonomous)

  • Off unless configured. Absent block ⇒ silent detection, exactly today's behaviour. Deploy ships notify: false.
  • One announcement per tick, however many patterns the scan turned up.
  • One announcement per cooldown (24h default) across all pairs, not per pair.
  • sev1 — the lowest severity there is, so quiet hours, away presence and snooze all suppress it.
  • A suppressed or dropped announcement is not retried and does not advance the cooldown clock. /routines still has it; that is what the page is for.
  • One announcement per pair for good: proposed_routines is UNIQUE(action, object) and the row survives dismissal, so a dismissed proposal can never be re-announced.
  • Body is pattern.PhraseRoutine's literal Russian, not LLM-worded — an inferred routine cannot arrive describing something Maven never observed.

Interval quality (the open item on #43)

MinEvents was 3, i.e. two intervals. Two gaps of similar length inside a ±50% band happen constantly — water the plants three Sundays running and the detector calls it a weekly routine. That was tolerable when detection only fired mid-conversation; it is not now that a scan of all history can announce itself, because a false positive costs a permanent dismissal of that action+object pair. 4 events / 3 intervals is the cheapest bar that separates a run from a repeat. A false negative costs one more observation and nothing else.

Verified

make build and make test (go test -race) both clean. New tests in cmd/mavend/patterns_test.go: silent by default, announced once when configured and never re-announced, suppressed when away (with the proposal row still written), and two patterns on one tick producing exactly one announcement with the second held by the cooldown. internal/pattern/detector_test.go updated for the new threshold, including a table asserting nothing is proposed below MinEvents.

Vikunja #247, Vikunja #43

Closes the second half of pattern inference. The digestion tick already scans every recorded action+object pair and writes a `proposed_routines` row (commit 67563ed on the base branch); until now that row reached nothing but the `/routines` page, so a pattern noticed while nobody was at the mic was only seen if he went looking. ## What changed - `cmd/mavend/tick.go` — `detectPatterns` now takes the tick's `loop.State` and hands each newly created proposal to a new `announceProposal`, which delivers it as a sev1 care nudge (`proposal:<action> <object>`) through `loop.Gate` + `delivery.Dispatcher` — the same path an accepted routine uses. No second delivery mechanism. - `internal/config/config.go` — new `pattern_proposals` block (`PatternProposalConfig{Notify, Cooldown}`), `DefaultProposalCooldown = 24h`. - `deploy/mavend.json` — the block, shipped with `notify: false`. - `internal/pattern/detector.go` — `MinEvents` 3 → 4. ## Restraint (Maven is not a nag, not autonomous) - **Off unless configured.** Absent block ⇒ silent detection, exactly today's behaviour. Deploy ships `notify: false`. - One announcement per tick, however many patterns the scan turned up. - One announcement per cooldown (24h default) across all pairs, not per pair. - sev1 — the lowest severity there is, so quiet hours, away presence and snooze all suppress it. - A suppressed or dropped announcement is **not** retried and does not advance the cooldown clock. `/routines` still has it; that is what the page is for. - One announcement per pair for good: `proposed_routines` is `UNIQUE(action, object)` and the row survives dismissal, so a dismissed proposal can never be re-announced. - Body is `pattern.PhraseRoutine`'s literal Russian, not LLM-worded — an inferred routine cannot arrive describing something Maven never observed. ## Interval quality (the open item on #43) `MinEvents` was 3, i.e. two intervals. Two gaps of similar length inside a ±50% band happen constantly — water the plants three Sundays running and the detector calls it a weekly routine. That was tolerable when detection only fired mid-conversation; it is not now that a scan of all history can announce itself, because a false positive costs a permanent dismissal of that action+object pair. 4 events / 3 intervals is the cheapest bar that separates a run from a repeat. A false negative costs one more observation and nothing else. ## Verified `make build` and `make test` (`go test -race`) both clean. New tests in `cmd/mavend/patterns_test.go`: silent by default, announced once when configured and never re-announced, suppressed when away (with the proposal row still written), and two patterns on one tick producing exactly one announcement with the second held by the cooldown. `internal/pattern/detector_test.go` updated for the new threshold, including a table asserting nothing is proposed below `MinEvents`. Vikunja #247, Vikunja #43
claude added 1 commit 2026-07-31 23:38:15 +02:00
The digestion tick already runs the pattern detector over all recorded
events (67563ed) and writes a proposed_routines row. What was missing is
the other half of #247: a proposal that nobody is at the mic for reaches
nothing but the /routines page, so a pattern noticed at 03:00 is only
seen if he goes looking.

This wires the tick's proposals into the existing care-delivery path
rather than a second channel: sev1 nudge, loop.Gate, dispatcher, same
routing table as an accepted routine. Restraints, since a feature that
speaks unprompted is the easiest way to turn Maven into a nag:

  - off unless configured — the new pattern_proposals block, absent by
    default, and deploy/mavend.json ships notify: false;
  - at most one announcement per tick however many patterns surfaced;
  - at most one per cooldown (24h default) across all pairs;
  - sev1, so quiet hours, away and snooze suppress it;
  - suppressed means dropped, not queued — /routines still has it;
  - once per pair for good, since proposed_routines is
    UNIQUE(action, object) and the row survives dismissal.

The body is pattern.PhraseRoutine's literal Russian, not LLM-worded, so
an inferred routine cannot arrive describing something never observed.

Also raises pattern.MinEvents from 3 to 4 — the interval-quality item on
#43. Two intervals with a ±50% band is a coincidence with a mean, not a
pattern, and now that a scan of all history can announce itself the cost
of a false positive is a permanent dismissal of that pair.
kami reviewed 2026-08-01 10:14:15 +02:00
@@ -10,3 +9,4 @@
// MinEvents events with 7-day intervals → stable pattern
base := time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC)
events := []Event{
{Action: "refill", Object: "cat_water", Ts: base},
Owner

what if not every 7 days, but sometimes 5, sometimes 8, sometimes 10, sometimes 3.

what if not every 7 days, but sometimes 5, sometimes 8, sometimes 10, sometimes 3.
claude reviewed 2026-08-01 11:23:10 +02:00
claude left a comment
Author
Contributor

The restraint is well built. Off unless configured. sev1, so the ordinary care gate suppresses it. One per tick, one per cooldown. The body is pattern.PhraseRoutine rather than model output. That last one matters most: an inferred routine cannot arrive worded as something she never observed.

Your MaxIntervalRatio catch is fixed on the tip branch at 7f42cc7. The detector now tests each interval against the median and requires 70 percent in band. So 7, 7, 7, 7, 20 no longer passes on a ratio of 2.9. Raising MinEvents to 4 here is the right companion change. The reasoning about dismissal being permanent is the correct way to weigh it.

One behaviour worth confirming, because the doc comment and the code read slightly differently.

detectAndPropose returns non-nil only when the row is newly created. So a pair gets exactly one chance to be announced: the tick that first proposes it. Combine that with one announcement per tick. The first tick over a populated history announces one pattern and permanently silences every other pattern in the same pass. The comment says "the rest are on /routines; they are not lost, they are just not shouted", which describes it accurately. But the cooldown then does almost no work, because a second announcement needs a second tick that discovers a genuinely new pair. If that is the intent, say so where the cooldown is documented. If the intent was "announce them one per day until they are all mentioned", this needs a queue rather than a counter.

Two smaller ones:

  • Cooldown gets its default twice: in applyDefaults and again in announceProposal. Harmless, and the second one covers a tickLoop built in a test without going through Load. Worth a word saying that is why.
  • lastProposalAt being in-memory is documented, and I agree with the choice. The case it does not cover is a crash loop, which would announce on every boot. sev1 plus quiet hours takes most of the sting out. Not worth persisting.

deploy/mavend.json shipping {"notify": false, "cooldown": "24h"} is equivalent to omitting the block. Keeping it visible so the knob is discoverable is the better trade.

The restraint is well built. Off unless configured. sev1, so the ordinary care gate suppresses it. One per tick, one per cooldown. The body is `pattern.PhraseRoutine` rather than model output. That last one matters most: an inferred routine cannot arrive worded as something she never observed. Your `MaxIntervalRatio` catch is fixed on the tip branch at `7f42cc7`. The detector now tests each interval against the median and requires 70 percent in band. So 7, 7, 7, 7, 20 no longer passes on a ratio of 2.9. Raising `MinEvents` to 4 here is the right companion change. The reasoning about dismissal being permanent is the correct way to weigh it. One behaviour worth confirming, because the doc comment and the code read slightly differently. `detectAndPropose` returns non-nil only when the row is newly created. So a pair gets exactly one chance to be announced: the tick that first proposes it. Combine that with one announcement per tick. The first tick over a populated history announces one pattern and permanently silences every other pattern in the same pass. The comment says "the rest are on /routines; they are not lost, they are just not shouted", which describes it accurately. But the cooldown then does almost no work, because a second announcement needs a second tick that discovers a genuinely new pair. If that is the intent, say so where the cooldown is documented. If the intent was "announce them one per day until they are all mentioned", this needs a queue rather than a counter. Two smaller ones: - `Cooldown` gets its default twice: in `applyDefaults` and again in `announceProposal`. Harmless, and the second one covers a `tickLoop` built in a test without going through `Load`. Worth a word saying that is why. - `lastProposalAt` being in-memory is documented, and I agree with the choice. The case it does not cover is a crash loop, which would announce on every boot. sev1 plus quiet hours takes most of the sting out. Not worth persisting. `deploy/mavend.json` shipping `{"notify": false, "cooldown": "24h"}` is equivalent to omitting the block. Keeping it visible so the knob is discoverable is the better trade.
kami closed this pull request 2026-08-01 14:51:39 +02:00
Owner

Landed on master. The stack was one linear chain, so #84 carried every commit from #50 up, and master now contains this branch in full. Merging this PR on its own is an empty diff, so it is closed rather than merged. The review findings for it were fixed in the 2026-08-01 pass and are on master as commits on the stack tip, not on this branch.

Landed on master. The stack was one linear chain, so #84 carried every commit from #50 up, and master now contains this branch in full. Merging this PR on its own is an empty diff, so it is closed rather than merged. The review findings for it were fixed in the 2026-08-01 pass and are on master as commits on the stack tip, not on this branch.

Pull request closed

Sign in to join this conversation.
No Reviewers
No Label
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: kami/Maven#54