Skip to content

Signal feedback loop: learn which signals users engage with #10

Description

@tyejcoleman

Problem

The heartbeat system evaluates all 14 signal types with static confluence weights defined in tierWeight (heartbeat_decide.go):

var tierWeight = map[string]int{
    TierImmediate: 10,
    TierElevated:  5,
    TierNormal:    3,
    TierLow:       1,
}

These weights never change. A user who always ignores CheckSentiment signals and always acts on CheckDeadlines still gets both weighted the same within their tier. The responseCooldownMultiplier() tracks overall response rate but doesn't differentiate by signal type.

Proposal

Add a signal_feedback table that records per-signal-type engagement:

CREATE TABLE signal_feedback (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    entity_id TEXT NOT NULL,
    agent_id TEXT NOT NULL DEFAULT 'default',
    tick_id TEXT,
    signal_type TEXT NOT NULL,       -- "deadlines", "sentiment", "pending_work", etc.
    signal_tier TEXT NOT NULL,
    fired_at TEXT NOT NULL,
    user_responded INTEGER,          -- NULL=unchecked, 0=no, 1=yes
    response_time_ms INTEGER,
    created_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX idx_signal_feedback_entity ON signal_feedback(entity_id, signal_type);

Behavior changes

  1. Recording: In watcher.go checkAll(), after a tick fires with ShouldAct=true, record one row per active signal type from the HeartbeatResult
  2. Response check: Extend checkResponseTracking() in heartbeat_decide.go to also update signal_feedback.user_responded using the same 2h window from GetHeartbeatActionsForResponseCheck()
  3. Weight adjustment: In evaluateShouldAct(), before confluence scoring, query per-signal engagement rates:
    SELECT signal_type, 
           AVG(CASE WHEN user_responded = 1 THEN 1.0 ELSE 0.0 END) as engage_rate
    FROM signal_feedback 
    WHERE entity_id = ? AND fired_at > datetime('now', '-14 days')
    GROUP BY signal_type
    HAVING COUNT(*) >= 5
  4. Apply multiplier: Adjust the effective weight: effective_weight = tierWeight[tier] * engageRateMultiplier(rate)
    • rate > 0.7: 1.5x (user cares about this)
    • rate 0.3-0.7: 1.0x (neutral)
    • rate < 0.3: 0.5x (user ignores this)
    • rate < 0.1: 0.25x (effectively suppressed)
  5. Rolling window: Auto-delete rows older than 30 days in the decay job

Files to modify

  • storage/sqlite_heartbeat.go — new table, insert/query/cleanup functions
  • storage/sqlite_migrate.go — migration for new table
  • heartbeat_decide.go — read learned weights in evaluateShouldAct(), extend checkResponseTracking()
  • watcher.go — record signal feedback after tick in checkAll()

Constraints

  • Minimum 5 samples per signal type before adjusting (avoid cold-start noise)
  • 14-day rolling window for rate calculation (behavior changes over time)
  • 30-day retention for raw rows
  • Multiplier bounds: [0.25, 1.5] to prevent total suppression or runaway boosting

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions