# DESIGN — Proactive action impact/cost prioritization (kb#123)

Status: **draft, for review** — written 2026-07-22
Owner: alvis · Written with Claude
Scope: kanboard Adolf task **#123**. This is a design/ruleset only — no wiring
into `openclaw.json`, no code. Claude implements it in a follow-up task.

Related, not duplicated here:
- **#122** (utility/ROI metrics via Langfuse) — that's the after-the-fact
  "was Adolf worth it" readout across all of Adolf's spend. This doc is the
  **before-the-fact gate** on one specific class of spend: proactive sends.
  #122's Langfuse data is a candidate future input to the acceptance-rate term
  below (§3.3), but this design does not depend on #122 landing first.
- **#124** (proactive cadence / cron schedule) — decides **when Adolf looks**
  (daily/weekly/monthly audit cadence, adapted to quota). This design decides
  **whether a specific candidate action fires** once #124 (or an ad-hoc
  trigger, e.g. reacting to a calendar change) has already produced one.
  #124 is the scheduler; this is the gate every candidate passes through
  regardless of what triggered it.
- **#125** (feedback loop) — this design's acceptance-probability term (§3.3)
  is a **consumer** of #125's feedback log. #125 is not built yet; §5 below
  specifies exactly what it needs to log, as a dependency, not an assumption.

---

## 1. Problem

Adolf can generate a proactive message (reminder, nudge, digest item) from
several places: the cadence jobs in #124, a reactive trigger (calendar event
changed, HA sensor fired, a task went overdue), or background reasoning
noticing something. Not every candidate should be sent — some are low-value,
some are redundant with something already said, some cost real Kimi quota to
formulate and the window is nearly exhausted.

This design is a **gate function**: given a candidate proactive action, decide
fire / suppress / defer, using a score computed from four inputs the task
description names, plus a threshold that scales with remaining quota.

## 2. Where the gate sits

```
 [trigger: #124 cadence job | reactive event | background noticing]
                     |
                     v
          candidate proactive action
          (draft content + metadata)
                     |
                     v
        ┌─────────────────────────┐
        │   IMPACT/COST GATE      │   <-- this design
        │   (score, threshold)    │
        └─────────────────────────┘
              |            |
           fire         suppress / defer
              |            |
       send via Matrix   log decision + reason
       log outcome slot   (no send; feedback loop
       for #125           has nothing to attach to)
```

The gate is a pure function of the candidate + current state. It does not
decide *what* to consider sending (#124's job) or *how* to learn from
responses (#125's job) — only whether a given candidate clears the bar right
now.

## 3. Scoring model

For each candidate action `a`, compute:

```
score(a) = (benefit(a) * accept_prob(a) * urgency(a)) / cost(a)
```

Ratio form, not a weighted sum: cost is a genuine denominator (token cost is
literally what you're trading against benefit), and the three numerator terms
are gates on each other, not additive alternatives — a high-benefit,
low-acceptance-probability action *should* be suppressed even if urgency is
high, not partially compensated the way a sum would allow. All four terms are
normalized to comparable ranges as defined below so no single term dominates
by scale alone.

### 3.1 Benefit — `benefit(a) ∈ [0, 1]`

"How important is this to the user, if accepted." Estimated by Adolf itself
(the model doing the drafting) using a small fixed rubric — this is a
judgment call, not a measurement, so keep the rubric coarse enough to be
stable across repeated runs:

| Band | Value | Examples |
|---|---|---|
| Critical | 1.0 | Hard deadline today/tomorrow, safety/health-adjacent, financial penalty if missed |
| High | 0.7 | Real deadline this week, blocks another person, irreversible if missed |
| Medium | 0.4 | Useful reminder, no hard deadline, low cost if ignored |
| Low | 0.15 | "Might be nice to know", trivia-adjacent, no consequence |

Adolf assigns the band as part of drafting the candidate (one extra field in
the same generation pass — no separate LLM call). This is inherently noisy;
it is corrected over time by the acceptance-probability term (§3.3), which is
grounded in actual logged outcomes rather than self-assessment.

### 3.2 Cost — `cost(a) ∈ (0, 1]`, token cost normalized

Raw cost is estimable *before* sending: `tokens_estimate(a)` = prompt tokens
to formulate (system + context already loaded for the turn, since it's
piggy-backing on an existing generation) + estimated output tokens for the
message itself. For a message that requires its **own** dedicated Kimi call
(true incremental cost) vs. one riding along inside an already-scheduled
cadence turn (near-zero marginal cost), these are very different costs — the
estimate must distinguish "marginal call I wouldn't otherwise make" from
"free byproduct of a call happening anyway":

```
cost_tokens(a) = marginal_prompt_tokens(a) + marginal_output_tokens(a)
```

where `marginal_*` is 0 (or near-0, e.g. a few output tokens) if the action
rides inside a scheduled #124 audit turn that would run regardless, and the
full call cost if it requires spinning up a fresh Kimi turn.

Normalize against a reference ceiling (a "typical expensive proactive send",
empirically ~2-3K tokens per the #122 baseline measurements of ~32.8K for a
full reply turn — a standalone proactive nudge should be far cheaper than a
full conversational turn, since it's one-directional with no back-and-forth):

```
cost(a) = clamp(cost_tokens(a) / COST_CEILING, floor=0.05, cap=1.0)
```

`COST_CEILING` = 3000 tokens (tunable constant, revisit once #122 gives real
distributions). The 0.05 floor stops a literally-free riding action from
dividing by ~0 and producing a runaway score — even "free" actions carry some
opportunity cost (attention, message-count against the 60/5h ceiling, not
just tokens).

### 3.3 Acceptance probability — `accept_prob(a) ∈ [0, 1]`

**This term has no data source yet.** It depends entirely on #125 (feedback
loop) being built and logging outcomes. Until then, use a flat prior:

```
accept_prob(a) = 0.5   # uninformative prior, pending #125
```

Once #125 logs `(action_class, outcome)` pairs (see §5's exact schema
requirement — this design does not invent history, it specifies what must
exist), compute a per-class empirical rate with Laplace smoothing so a class
with zero or few samples doesn't overfit to noise:

```
accept_prob(class) = (accepted_count(class) + 1) / (total_count(class) + 2)
```

`class` is a coarse bucket, not per-message: e.g. `{calendar_reminder,
task_overdue, ha_anomaly, family_wiki_gap, digest_item, ...}` — one row per
class, not per exact message text, since exact-text history rarely repeats
but the class does. A candidate's class is assigned at draft time (same pass
as §3.1's benefit band).

Recency matters more than total count — a user who started dismissing
`calendar_reminder` last week should pull that class's rate down faster than
five-year-old acceptances prop it up. Use a decayed count (e.g. half-life of
30 days, or simply windowing to the trailing N=50 outcomes per class) rather
than an all-time average, once enough volume exists to make decay
meaningful.

### 3.4 Urgency — `urgency(a) ∈ [0, 1]`

Distinct from benefit: benefit is "how much it matters", urgency is "how soon
it stops being actionable". A time-decay curve against the nearest relevant
deadline (`due_at`) known for the candidate (calendar event start, task
`date_due`, HA-derived risk window):

```
hours_to_deadline = (due_at - now) in hours

urgency(a) =
    1.0                              if hours_to_deadline <= 1
    1.0 - 0.6 * (h - 1) / 23         if 1 < h <= 24     (1.0 -> 0.4 over the day)
    0.4 * exp(-(h - 24) / 168)       if h > 24          (decays over the following week)
    0.2                              if no deadline (informational-only action)
```

Concretely: something due within the hour scores 1.0, something due
tomorrow ~0.4-1.0 depending on how close, something a week out trails off
toward the 0.2 floor for undated nudges. This is a simple monotonic decay, not
a precise model — tune the constants once real cadence data exists (#124).

### 3.5 Putting it together

```
score(a) = (benefit(a) * accept_prob(a) * urgency(a)) / cost(a)
```

Range: numerator ∈ [0, 1], denominator ∈ [0.05, 1], so
`score(a) ∈ [0, 20]` in the degenerate cheapest/most-urgent/most-beneficial
case. In practice, typical scores cluster well below that ceiling — the
threshold (§4) is calibrated empirically against observed scores, not derived
analytically from the range.

## 4. Firing rule

```
fire(a)  iff  score(a) >= threshold(current_quota_state)
else          suppress(a)   # or defer(a), see below
```

### 4.1 Quota signal

Read `adolf-llm:8010/usage` (confirmed live shape, sampled 2026-07-22):

```json
{
  "weekly":   {"pct": 32, "used": 32, "limit": 100, "remaining": 68, "resets": "..."},
  "window_5h":{"pct": 79, "used": 79, "limit": 100, "remaining": 21, "resets": "..."},
  "stale": false
}
```

Use the **tighter** of the two windows — whichever pct is higher is the
binding constraint right now:

```
quota_pressure = max(weekly.pct, window_5h.pct) / 100      # ∈ [0, 1]
```

If `stale: true` (Kimi login/session broken, per the adolf-llm fallback
behavior), treat as `quota_pressure = 1.0` (most conservative) — an unknown
quota state should suppress non-critical sends, not fire them.

### 4.2 Threshold as a function of quota pressure

```
threshold(quota_pressure) = T_BASE + (T_MAX - T_BASE) * quota_pressure^2
```

- `T_BASE` = 0.3 — threshold when quota is abundant (pressure ~0): let most
  medium-benefit things through.
- `T_MAX` = 3.0 — threshold when quota is nearly exhausted (pressure ~1):
  only near-maximal score (critical benefit, high acceptance history, urgent,
  cheap) still fires.
- Squaring `quota_pressure` keeps the threshold flat and permissive through
  low-to-mid pressure (nothing changes until quota actually gets tight) and
  then rises steeply as the window approaches exhaustion — matching the
  actual failure mode (403 usage-limit) which is a cliff, not a slope.

This gives a single tunable curve with two constants, both revisitable once
#122 supplies real score distributions and false-negative/positive rates.

### 4.3 Fire / suppress / defer

- **fire**: send now.
- **suppress**: below threshold and no deadline pressure — drop it. Log the
  decision (§5) but do not re-surface it later; if it's still relevant, the
  next cadence pass (#124) will regenerate it as a fresh candidate with
  updated urgency.
- **defer**: below threshold *only because of quota pressure*, but
  `urgency(a) >= 0.8` (i.e., something time-critical got starved by a quota
  cliff, not by low benefit). Requeue for immediate re-evaluation once
  `quota_pressure` drops (next window reset, per `resets` timestamp in the
  usage payload) rather than silently dropping it. This is the one exception
  to "gate is stateless" — a deferred item carries state (its own candidate
  record) until it either fires or its deadline passes, at which point it is
  logged as a missed/expired suppression, not silently lost.

## 5. Dependency: what #125's feedback log must contain

This design's accept_prob term (§3.3) is inert without it. #125 owns building
the collection mechanism (reactions, "+/-/неактуально" replies); this design
only specifies the **shape** the gate needs to consume, so the two tasks
don't diverge on schema:

```
proactive_outcome {
  action_class:   string   # matches the class taxonomy in §3.3, e.g. "calendar_reminder"
  sent_at:        timestamp
  benefit_band:   float    # the benefit(a) value used at send time, for later calibration
  cost_tokens:    int      # actual cost, for calibrating COST_CEILING
  urgency_at_send: float
  outcome:        enum { accepted, dismissed, ignored, irrelevant }
  responded_at:   timestamp | null
}
```

`ignored` (no response within some window, e.g. 24h) must be distinguished
from `dismissed` (explicit "−") — an ignored item is weaker negative signal
than an explicit rejection and should decay the acceptance rate less
aggressively. Without this distinction the Laplace-smoothed rate in §3.3
conflates "user didn't care" with "user was just busy."

Every **suppressed** and **deferred** candidate should also be logged (not
just fired ones) with `outcome: not_sent` — this is what lets a later audit
(#122) compute false-suppression rate (was a suppressed item actually needed?
only knowable in hindsight, e.g. if the same underlying deadline later caused
a problem) as well as false-fire rate.

## 6. Worked example

Candidate: "reminder that the Seafile SSL cert renews in 3 days" (from a
#124 daily cadence audit, riding along inside that scheduled call).

- `benefit`: Medium band → 0.4 (annoying if missed, not critical — auto-renew
  likely already configured, this is a check not a fire drill).
- `cost`: marginal — rides inside the already-running daily audit call, say
  ~150 marginal output tokens → `150/3000 = 0.05` → floored at 0.05.
- `accept_prob`: no #125 data yet → flat prior 0.5.
- `urgency`: `hours_to_deadline` = 72h → falls in the `h > 24` branch:
  `0.4 * exp(-(72-24)/168) = 0.4 * exp(-0.286) ≈ 0.4 * 0.751 ≈ 0.30`.

`score = (0.4 * 0.5 * 0.30) / 0.05 = 0.06 / 0.05 = 1.2`

At `quota_pressure = 0` (abundant quota), `threshold = 0.3` → **1.2 ≥ 0.3,
fires.** At `quota_pressure = 1` (window nearly exhausted, matching the
measured 79% 5h-window sample above, rounding up toward the cliff),
`threshold = 3.0` → **1.2 < 3.0, suppressed** (not deferred: urgency 0.30 is
well under the 0.8 defer bar) — correctly deprioritized under quota pressure
in favor of anything more urgent or already proven to land well.

## 7. Open parameters to tune post-implementation

Everything with a concrete numeric constant above (`COST_CEILING`, `T_BASE`,
`T_MAX`, the urgency decay constants, the defer bar) is a starting guess
consistent with the measurements already on hand (#122's token baseline, the
live `/usage` sample). None of it is load-bearing on the *shape* of the
model — only on where the dial sits. Revisit once:

- #125 supplies real `accept_prob` data (replacing the flat 0.5 prior is the
  single highest-value follow-up — everything else is a reasonable guess,
  this term is currently a placeholder).
- #122's Langfuse integration supplies real per-action token costs to
  recalibrate `COST_CEILING`.
- A few weeks of fire/suppress/defer logs (§5) exist to check the threshold
  isn't systematically over- or under-firing.

## 8. Acceptance check against kb#123

- Scoring formula with each term defined, ranged, and its estimation method
  stated: §3.
- Firing rule (fire only if impact/cost exceeds a threshold): §4.
- Threshold tunable to current quota, against the real `adolf-llm:8010/usage`
  signal: §4.1-4.2.
- Acceptance-probability term flagged as dependent on unbuilt history
  (#125), with the exact log schema it needs specified rather than
  fabricated: §3.3, §5.
- Cross-references to sibling tasks #122, #124, #125 without duplicating
  their scope: header + inline.
