Volume 2: Organizational Intelligence Platforms

Pattern 6: Composite Health Scoring

Intent

Transform raw interaction data into a single, actionable health score (0-100) that reflects overall relationship quality, predicts risk, and enables tier-based segmentation and prioritization.

Also Known As

  • Engagement Score
  • Health Index
  • Relationship Score
  • Vitality Metric
  • Customer Health Score

Problem

Raw interaction data is overwhelming and hard to act on.

Pattern 1 (Universal Event Log) gives Sarah comprehensive data: - Martinez family: 247 logged interactions over 8 months - 23 emails sent, 6 opened (26% open rate) - 4 SMS sent, 0 replies - 2 phone calls, 1 answered - Portal: last login 47 days ago - Payments: 2 of 4 late (average 12 days) - Events: attended 2 of 8 (25%) - Volunteer hours: 0

But Sarah needs one number: "How healthy is this relationship?"

Without a composite score: - Can't quickly triage 100 families - Can't prioritize who needs attention - Can't compare families objectively - Can't track changes over time - Can't trigger automated actions

The challenge: Combine dozens of behavioral signals into single actionable metric.

Context

When this pattern applies:

  • Multiple interaction types tracked (not just one metric)
  • Need to prioritize attention across many entities
  • Want to trigger automated interventions based on health
  • Building predictive models (health score as feature)
  • Leadership needs simple dashboard metric

When this pattern may not be needed:

  • Only tracking one or two simple metrics (can use those directly)
  • Very small scale where human intuition suffices (<20 entities)
  • Relationships too complex for single score (may need multi-dimensional)

Forces

Competing concerns:

1. Simplicity vs Accuracy - Single score is actionable - But compressing complexity loses nuance - Balance: Composite score plus ability to drill down to components

2. Universal vs Domain-Specific - Want formula that works across domains - But each domain has unique health signals - Balance: Framework with domain-specific weights

3. Objective vs Subjective - Calculated scores are objective, consistent - But miss qualitative signals humans detect - Balance: Algorithmic score with manual override option

4. Absolute vs Relative - Score 75/100 - is that good? - Depends on context and distribution - Balance: Both absolute score and percentile rank

5. Current vs Trend - Current score shows status now - But trend shows direction (improving/declining) - Balance: Track both score and velocity

Solution

Build a composite health score from multiple weighted components:

Formula framework:

Health Score = Σ (Component Score × Component Weight)

Where:
- Each component measures specific behavior (0-100)
- Weights reflect relative importance
- Sum of weights = 1.0
- Final score scaled to 0-100

Common components:

1. Communication Responsiveness (20-30% weight) - Email open rate - Email click rate - SMS response rate - Phone answer rate - Response time to messages

2. Platform Engagement (15-25% weight) - Portal login frequency - Pages viewed per session - Actions taken - Time since last login - Feature usage breadth

3. Financial Health (20-30% weight) - Payment on-time rate - Days late when late - Outstanding balance age - Payment method reliability - Billing issue frequency

4. Participation/Activity (15-25% weight) - Event attendance rate - Volunteer hours - Community involvement - Content contribution - Referrals made

5. Relationship Tenure (5-15% weight) - Time since enrollment - Consistency over time - Historical engagement - Churn risk indicators

6. Sentiment/Satisfaction (optional 10-20%) - Survey responses - Support ticket sentiment - Review/rating scores - Qualitative feedback

Structure

Health Metrics Tables

-- Store calculated health scores
CREATE TABLE family_engagement_metrics (
  metric_id INT PRIMARY KEY IDENTITY(1,1),
  family_id INT NOT NULL,

  -- Composite score
  engagement_score DECIMAL(5,2) NOT NULL,  -- 0-100
  engagement_tier VARCHAR(20),  -- 'high', 'medium', 'low', 'at_risk'

  -- Component scores (each 0-100)
  communication_score DECIMAL(5,2),
  platform_engagement_score DECIMAL(5,2),
  financial_health_score DECIMAL(5,2),
  participation_score DECIMAL(5,2),
  tenure_score DECIMAL(5,2),

  -- Metadata
  calculation_date DATETIME2 DEFAULT GETDATE(),
  calculation_period_days INT DEFAULT 90,  -- Lookback window

  -- Change tracking
  previous_score DECIMAL(5,2),
  score_delta DECIMAL(5,2),  -- Change since last calculation
  score_velocity VARCHAR(20),  -- 'improving', 'stable', 'declining'

  -- Percentile (relative to all families)
  percentile_rank DECIMAL(5,2),  -- 0-100, 90 = better than 90% of families

  CONSTRAINT FK_metrics_family FOREIGN KEY (family_id) 
    REFERENCES families(family_id),
  CONSTRAINT UQ_family_metric UNIQUE (family_id)
);

-- Index for common queries
CREATE INDEX IX_engagement_score ON family_engagement_metrics(engagement_score DESC);
CREATE INDEX IX_engagement_tier ON family_engagement_metrics(engagement_tier);
CREATE INDEX IX_score_velocity ON family_engagement_metrics(score_velocity);

-- Store component calculation details
CREATE TABLE score_components (
  component_id INT PRIMARY KEY IDENTITY(1,1),
  metric_id INT NOT NULL,

  component_name VARCHAR(100),
  raw_value DECIMAL(10,2),  -- Actual measured value
  normalized_score DECIMAL(5,2),  -- 0-100 normalized
  weight DECIMAL(4,3),  -- Weight in composite
  contribution DECIMAL(5,2),  -- weighted_score * weight

  CONSTRAINT FK_component_metric FOREIGN KEY (metric_id) 
    REFERENCES family_engagement_metrics(metric_id)
);

Implementation

Health Score Calculator

class HealthScoreCalculator {
  constructor(db, config = {}) {
    this.db = db;
    this.lookbackDays = config.lookbackDays || 90;

    // Component weights (must sum to 1.0)
    this.weights = config.weights || {
      communication: 0.25,
      platform: 0.20,
      financial: 0.30,
      participation: 0.15,
      tenure: 0.10
    };

    // Validate weights sum to 1.0
    const sum = Object.values(this.weights).reduce((a, b) => a + b, 0);
    if (Math.abs(sum - 1.0) > 0.01) {
      throw new Error(`Weights must sum to 1.0, got ${sum}`);
    }
  }

  async calculateForFamily(familyId) {
    const cutoffDate = this.getCutoffDate();

    // Calculate each component
    const components = {
      communication: await this.calculateCommunicationScore(familyId, cutoffDate),
      platform: await this.calculatePlatformScore(familyId, cutoffDate),
      financial: await this.calculateFinancialScore(familyId, cutoffDate),
      participation: await this.calculateParticipationScore(familyId, cutoffDate),
      tenure: await this.calculateTenureScore(familyId, cutoffDate)
    };

    // Calculate composite score
    let compositeScore = 0;
    const componentDetails = [];

    for (const [name, score] of Object.entries(components)) {
      const weight = this.weights[name];
      const contribution = score * weight;
      compositeScore += contribution;

      componentDetails.push({
        component_name: name,
        raw_value: score,
        normalized_score: score,
        weight: weight,
        contribution: contribution
      });
    }

    // Get previous score for delta calculation
    const previous = await this.getPreviousScore(familyId);
    const scoreDelta = previous ? compositeScore - previous.engagement_score : 0;
    const velocity = this.determineVelocity(scoreDelta);

    // Determine tier
    const tier = this.determineTier(compositeScore);

    // Calculate percentile
    const percentile = await this.calculatePercentile(compositeScore);

    // Save to database
    await this.saveMetrics(familyId, {
      engagement_score: compositeScore,
      engagement_tier: tier,
      components: components,
      previous_score: previous?.engagement_score,
      score_delta: scoreDelta,
      score_velocity: velocity,
      percentile_rank: percentile
    }, componentDetails);

    return {
      familyId,
      score: compositeScore,
      tier,
      delta: scoreDelta,
      velocity,
      percentile,
      components,
      componentDetails
    };
  }

  getCutoffDate() {
    const date = new Date();
    date.setDate(date.getDate() - this.lookbackDays);
    return date;
  }

  async calculateCommunicationScore(familyId, cutoffDate) {
    const results = await this.db.query(`
      SELECT 
        -- Email metrics
        SUM(CASE WHEN interaction_type = 'email_sent' THEN 1 ELSE 0 END) as emails_sent,
        SUM(CASE WHEN interaction_type = 'email_opened' THEN 1 ELSE 0 END) as emails_opened,
        SUM(CASE WHEN interaction_type = 'email_clicked' THEN 1 ELSE 0 END) as emails_clicked,

        -- SMS metrics
        SUM(CASE WHEN interaction_type = 'sms_sent' THEN 1 ELSE 0 END) as sms_sent,
        SUM(CASE WHEN interaction_type = 'sms_replied' THEN 1 ELSE 0 END) as sms_replied,

        -- Phone metrics
        SUM(CASE WHEN interaction_type = 'phone_call_made' THEN 1 ELSE 0 END) as calls_made,
        SUM(CASE WHEN interaction_type = 'phone_call_made' AND outcome = 'answered' THEN 1 ELSE 0 END) as calls_answered
      FROM interaction_log
      WHERE family_id = ?
        AND interaction_timestamp >= ?
    `, [familyId, cutoffDate]);

    const r = results[0];

    // Calculate sub-scores
    const emailScore = r.emails_sent > 0 
      ? ((r.emails_opened / r.emails_sent) * 50 + (r.emails_clicked / r.emails_sent) * 50) 
      : 0;

    const smsScore = r.sms_sent > 0
      ? (r.sms_replied / r.sms_sent) * 100
      : 0;

    const phoneScore = r.calls_made > 0
      ? (r.calls_answered / r.calls_made) * 100
      : 0;

    // Weighted average (email weighted higher if used more)
    const totalOutreach = r.emails_sent + r.sms_sent + r.calls_made;
    if (totalOutreach === 0) return 50; // No data = neutral score

    const score = (
      (emailScore * r.emails_sent) +
      (smsScore * r.sms_sent) +
      (phoneScore * r.calls_made)
    ) / totalOutreach;

    return Math.min(100, Math.max(0, score));
  }

  async calculatePlatformScore(familyId, cutoffDate) {
    const results = await this.db.query(`
      SELECT 
        COUNT(DISTINCT DATE(interaction_timestamp)) as days_active,
        COUNT(CASE WHEN interaction_type = 'portal_login' THEN 1 END) as logins,
        COUNT(CASE WHEN interaction_type = 'portal_page_viewed' THEN 1 END) as pages_viewed,
        COUNT(CASE WHEN interaction_type LIKE '%_downloaded' OR interaction_type LIKE '%_submitted' THEN 1 END) as actions_taken,
        MAX(interaction_timestamp) as last_activity
      FROM interaction_log
      WHERE family_id = ?
        AND channel = 'portal'
        AND interaction_timestamp >= ?
    `, [familyId, cutoffDate]);

    const r = results[0];

    if (!r.logins) return 0; // No portal use

    // Multiple factors
    const daysSinceActivity = Math.floor((Date.now() - new Date(r.last_activity)) / (1000 * 60 * 60 * 24));
    const recencyScore = Math.max(0, 100 - (daysSinceActivity * 2)); // 2 points per day

    const frequencyScore = Math.min(100, (r.days_active / this.lookbackDays) * 100 * 5); // 5x multiplier

    const engagementDepth = r.logins > 0 
      ? Math.min(100, (r.pages_viewed / r.logins) * 20 + (r.actions_taken / r.logins) * 30)
      : 0;

    // Weighted combination
    const score = (recencyScore * 0.4) + (frequencyScore * 0.3) + (engagementDepth * 0.3);

    return Math.min(100, Math.max(0, score));
  }

  async calculateFinancialScore(familyId, cutoffDate) {
    const results = await this.db.query(`
      SELECT 
        COUNT(*) as total_payments,
        SUM(CASE WHEN outcome = 'paid_on_time' THEN 1 ELSE 0 END) as on_time_payments,
        AVG(CASE WHEN outcome = 'paid_late' THEN CAST(JSON_VALUE(metadata, '$.days_late') AS INT) ELSE 0 END) as avg_days_late,
        SUM(CASE WHEN outcome = 'unpaid' THEN 1 ELSE 0 END) as unpaid_count,
        MAX(CASE WHEN outcome = 'unpaid' THEN DATEDIFF(day, interaction_timestamp, GETDATE()) ELSE 0 END) as oldest_unpaid_days
      FROM interaction_log
      WHERE family_id = ?
        AND interaction_type = 'payment_received' OR interaction_type = 'payment_due'
        AND interaction_timestamp >= ?
    `, [familyId, cutoffDate]);

    const r = results[0];

    if (r.total_payments === 0) return 50; // No payment history = neutral

    // On-time rate (most important)
    const onTimeScore = (r.on_time_payments / r.total_payments) * 100;

    // Penalty for late payments
    const latenessPenalty = Math.min(50, r.avg_days_late * 2); // Max 50 point penalty

    // Penalty for unpaid balance
    const unpaidPenalty = r.unpaid_count > 0 
      ? Math.min(40, r.unpaid_count * 10 + r.oldest_unpaid_days * 0.5)
      : 0;

    const score = onTimeScore - latenessPenalty - unpaidPenalty;

    return Math.min(100, Math.max(0, score));
  }

  async calculateParticipationScore(familyId, cutoffDate) {
    const results = await this.db.query(`
      SELECT 
        -- Events
        COUNT(CASE WHEN interaction_type = 'event_attended' THEN 1 END) as events_attended,
        COUNT(CASE WHEN interaction_type = 'event_invited' THEN 1 END) as events_invited,

        -- Volunteering
        SUM(CASE WHEN interaction_type = 'volunteer_hours_logged' 
          THEN CAST(JSON_VALUE(metadata, '$.hours') AS DECIMAL) 
          ELSE 0 END) as volunteer_hours,

        -- Community
        COUNT(CASE WHEN interaction_type = 'referral_made' THEN 1 END) as referrals_made,
        COUNT(CASE WHEN interaction_type = 'feedback_submitted' THEN 1 END) as feedback_given
      FROM interaction_log
      WHERE family_id = ?
        AND interaction_timestamp >= ?
    `, [familyId, cutoffDate]);

    const r = results[0];

    // Event attendance rate
    const attendanceScore = r.events_invited > 0
      ? (r.events_attended / r.events_invited) * 100
      : 50; // No invites = neutral

    // Volunteer contribution
    const volunteerScore = Math.min(100, (r.volunteer_hours || 0) * 10); // 10 points per hour, max 100

    // Community contribution
    const communityScore = Math.min(100, (r.referrals_made * 20) + (r.feedback_given * 10));

    // Weighted average
    const score = (attendanceScore * 0.5) + (volunteerScore * 0.3) + (communityScore * 0.2);

    return Math.min(100, Math.max(0, score));
  }

  async calculateTenureScore(familyId, cutoffDate) {
    const results = await this.db.query(`
      SELECT 
        enrollment_date,
        DATEDIFF(day, enrollment_date, GETDATE()) as days_enrolled
      FROM families
      WHERE family_id = ?
    `, [familyId]);

    const r = results[0];
    const daysEnrolled = r.days_enrolled;

    // Tenure bonus increases over time, plateaus at 2 years
    // New families: lower score (higher churn risk)
    // Established families: higher score (proven loyalty)

    if (daysEnrolled < 30) return 20; // Very new
    if (daysEnrolled < 90) return 40; // First semester
    if (daysEnrolled < 180) return 60; // Second semester
    if (daysEnrolled < 365) return 75; // First year
    if (daysEnrolled < 730) return 90; // Second year
    return 100; // 2+ years = maximum tenure score
  }

  determineTier(score) {
    if (score >= 80) return 'high';
    if (score >= 60) return 'medium';
    if (score >= 40) return 'low';
    return 'at_risk';
  }

  determineVelocity(scoreDelta) {
    if (scoreDelta > 5) return 'improving';
    if (scoreDelta < -5) return 'declining';
    return 'stable';
  }

  async calculatePercentile(score) {
    const result = await this.db.query(`
      SELECT COUNT(*) as lower_count
      FROM family_engagement_metrics
      WHERE engagement_score < ?
    `, [score]);

    const totalResult = await this.db.query(`
      SELECT COUNT(*) as total_count
      FROM family_engagement_metrics
    `);

    const percentile = (result[0].lower_count / totalResult[0].total_count) * 100;
    return Math.round(percentile);
  }

  async getPreviousScore(familyId) {
    const result = await this.db.query(`
      SELECT engagement_score, calculation_date
      FROM family_engagement_metrics
      WHERE family_id = ?
    `, [familyId]);

    return result[0] || null;
  }

  async saveMetrics(familyId, metrics, componentDetails) {
    // Upsert main metrics
    await this.db.query(`
      INSERT INTO family_engagement_metrics (
        family_id, engagement_score, engagement_tier,
        communication_score, platform_engagement_score, financial_health_score,
        participation_score, tenure_score,
        previous_score, score_delta, score_velocity, percentile_rank,
        calculation_period_days
      ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
      ON CONFLICT (family_id) DO UPDATE SET
        engagement_score = EXCLUDED.engagement_score,
        engagement_tier = EXCLUDED.engagement_tier,
        communication_score = EXCLUDED.communication_score,
        platform_engagement_score = EXCLUDED.platform_engagement_score,
        financial_health_score = EXCLUDED.financial_health_score,
        participation_score = EXCLUDED.participation_score,
        tenure_score = EXCLUDED.tenure_score,
        previous_score = family_engagement_metrics.engagement_score,
        score_delta = EXCLUDED.score_delta,
        score_velocity = EXCLUDED.score_velocity,
        percentile_rank = EXCLUDED.percentile_rank,
        calculation_date = GETDATE()
    `, [
      familyId,
      metrics.engagement_score,
      metrics.engagement_tier,
      metrics.components.communication,
      metrics.components.platform,
      metrics.components.financial,
      metrics.components.participation,
      metrics.components.tenure,
      metrics.previous_score,
      metrics.score_delta,
      metrics.score_velocity,
      metrics.percentile_rank,
      this.lookbackDays
    ]);
  }

  async calculateForAllFamilies() {
    const families = await this.db.query(`
      SELECT family_id FROM families WHERE enrolled_current_semester = 1
    `);

    const results = [];
    for (const family of families) {
      const result = await this.calculateForFamily(family.family_id);
      results.push(result);
    }

    return results;
  }
}

module.exports = HealthScoreCalculator;

Usage Example

const calculator = new HealthScoreCalculator(db, {
  lookbackDays: 90,
  weights: {
    communication: 0.25,
    platform: 0.20,
    financial: 0.30,
    participation: 0.15,
    tenure: 0.10
  }
});

// Calculate for one family
const result = await calculator.calculateForFamily(187);
console.log(`
Family ${result.familyId} Health Report:
  Overall Score: ${result.score.toFixed(1)}/100
  Tier: ${result.tier}
  Trend: ${result.velocity} (${result.delta > 0 ? '+' : ''}${result.delta.toFixed(1)} points)
  Percentile: ${result.percentile}th (better than ${result.percentile}% of families)

  Component Breakdown:
    Communication: ${result.components.communication.toFixed(1)}
    Platform: ${result.components.platform.toFixed(1)}
    Financial: ${result.components.financial.toFixed(1)}
    Participation: ${result.components.participation.toFixed(1)}
    Tenure: ${result.components.tenure.toFixed(1)}
`);

// Calculate for all families (run nightly)
const allResults = await calculator.calculateForAllFamilies();
console.log(`Calculated health scores for ${allResults.length} families`);

Variations

By Domain

Homeschool Co-op:

weights: {
  communication: 0.25,
  platform: 0.20,
  financial: 0.30,  // Payment reliability crucial
  participation: 0.15,
  tenure: 0.10
}

Property Management:

weights: {
  communication: 0.20,
  platform: 0.10,  // Less portal reliance
  financial: 0.40,  // Rent payment most critical
  maintenance: 0.20,  // Request frequency, issue resolution
  tenure: 0.10
}

SaaS Product:

weights: {
  communication: 0.15,
  platform: 0.40,  // Product usage most important
  financial: 0.25,  // Payment + expansion
  support_tickets: 0.10,
  tenure: 0.10
}

By Sophistication Level

Simple (3 components): - Activity (50%) - just using the product - Financial (30%) - paying on time - Tenure (20%) - how long they've been around

Standard (5 components): - As shown in main implementation

Advanced (7+ components): - Add sentiment analysis - Add NPS/satisfaction scores - Add feature adoption depth - Add referral quality - Add support interaction quality

By Calculation Frequency

Real-time: - Update on every interaction - Good for: immediate triggering, live dashboards - Cost: high compute, frequent writes

Daily: - Calculate nightly for all families - Good for: most use cases - Cost: moderate, predictable

Weekly: - Calculate once per week - Good for: low-urgency scenarios - Cost: minimal

On-demand: - Calculate when needed (viewing family profile) - Good for: small scale, infrequent access - Cost: varies with usage

Consequences

Benefits

1. Single actionable metric "Martinez family score: 28/100, at-risk tier, declining rapidly" - Sarah knows exactly who needs attention.

2. Objective prioritization Sort by score, work top-down. No guesswork, no favoritism.

3. Trend detection Score was 76 three months ago, now 28. Rapid decline triggers alert.

4. Tier-based automation - High tier (80-100): Minimal touch, celebrate success - Medium tier (60-79): Standard communications - Low tier (40-59): Increased engagement attempts - At-risk (<40): Urgent intervention, personal outreach

5. Predictive power Low scores predict churn. Intervene before withdrawal.

6. Dashboard simplicity Leadership sees one number, can drill down if needed.

7. Benchmark comparisons "Our average score is 68. Industry average is 72. We need improvement."

Costs

1. Oversimplification Complex relationships reduced to single number. Nuance lost.

2. Weight subjectivity Why 30% financial, not 35%? Weights are judgment calls.

3. Gaming potential If families learn the algorithm, they might game it (like credit scores).

4. Computation overhead Calculating scores for thousands of entities takes time and compute.

5. False precision Score of 73 vs 74 - is that real difference or noise?

6. Maintenance burden Weights may need tuning over time as business changes.

Sample Code

Dashboard query - who needs attention:

async function getAttentionQueue(limit = 10) {
  return await db.query(`
    SELECT 
      f.family_id,
      f.family_name,
      fem.engagement_score,
      fem.engagement_tier,
      fem.score_delta,
      fem.score_velocity,
      fem.calculation_date
    FROM family_engagement_metrics fem
    JOIN families f ON fem.family_id = f.family_id
    WHERE f.enrolled_current_semester = 1
      AND (
        fem.engagement_tier = 'at_risk'
        OR (fem.engagement_tier = 'low' AND fem.score_velocity = 'declining')
      )
    ORDER BY 
      CASE fem.engagement_tier
        WHEN 'at_risk' THEN 1
        WHEN 'low' THEN 2
        ELSE 3
      END,
      fem.engagement_score ASC
    LIMIT ?
  `, [limit]);
}

Known Uses

Homeschool Co-op Intelligence Platform - 100 families scored nightly - Average score: 68/100 - Identified 12 at-risk families (scores <40) - Intervened with 9, prevented 6 withdrawals - ROI: 6 families × $450 = $2,700 saved

SaaS Companies (inspiration) - Gainsight: Customer health scores - ChurnZero: Engagement scoring - Totango: SuccessScore

CRM Platforms - Salesforce: Lead scoring - HubSpot: Contact scoring - Pipeline: Deal health scores

Requires: - Pattern 1: Universal Event Log - raw data source - Pattern 3: Multi-Channel Tracking - complete interaction picture - Pattern 4: Interaction Outcome Classification - outcomes inform scoring

Enables: - Pattern 7: Multi-Dimensional Risk Assessment - health score is one dimension - Pattern 8: Tier-Based Segmentation - tiers determined by score - Pattern 9: Early Warning Signals - score changes trigger alerts - Pattern 15: Intervention Recommendation Engine - score determines intervention

Enhanced by: - Pattern 10: Engagement Velocity Tracking - rate of change matters - Pattern 12: Risk Stratification Models - predictive models use score as feature

References

Academic Foundations

  • Mehta, Nick, Dan Steinman, and Lincoln Murphy (2016). Customer Success. Wiley. ISBN: 978-1119167969 - Health scoring for customer success
  • Kaplan, Robert S., and David P. Norton (1996). The Balanced Scorecard. Harvard Business Press. ISBN: 978-0875846514 - Multi-dimensional measurement
  • Niven, Paul R. (2014). Balanced Scorecard Evolution. Wiley. ISBN: 978-1118558713 - Advanced scorecard techniques

Health Scoring Methodologies

Practical Implementation

Machine Learning for Scoring

  • Pattern 7: Multi-Dimensional Risk - Risk scoring uses similar techniques
  • Pattern 8: Tier-Based Segmentation - Health scores determine tiers
  • Pattern 9: Early Warning Signals - Health score changes trigger alerts
  • Pattern 12: Risk Stratification Models - Health as input feature
  • Volume 3, Pattern 6: Domain-Aware Validation - Validate health components

Tools & Services