> ## Documentation Index
> Fetch the complete documentation index at: https://docs.gorules.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Risk assessment and scoring

> Build scoring models that evaluate risk from multiple weighted factors.

export const DecisionGraphViz = ({nodes = [], edges = [], highlighted = null, title = null}) => {
  const nodeColors = {
    input: {
      bg: '#f8fafc',
      border: '#94a3b8',
      text: '#475569',
      icon: '→'
    },
    output: {
      bg: '#f8fafc',
      border: '#94a3b8',
      text: '#475569',
      icon: '←'
    },
    table: {
      bg: '#fefce8',
      border: '#d9c860',
      text: '#7c6f2a',
      icon: '▤'
    },
    expression: {
      bg: '#f5f3ff',
      border: '#a78bda',
      text: '#5b4a8c',
      icon: 'ƒ'
    },
    function: {
      bg: '#fdf2f8',
      border: '#e293b7',
      text: '#8c4a6d',
      icon: '{ }'
    },
    switch: {
      bg: '#f0fdfa',
      border: '#6bc4b8',
      text: '#3d7a72',
      icon: '◇'
    }
  };
  const nodeWidth = 140;
  const nodeHeight = 64;
  const horizontalGap = 80;
  const verticalGap = 24;
  const inDegree = {};
  const outEdges = {};
  nodes.forEach(n => {
    inDegree[n.id] = 0;
    outEdges[n.id] = [];
  });
  edges.forEach(e => {
    inDegree[e.to] = (inDegree[e.to] || 0) + 1;
    outEdges[e.from] = outEdges[e.from] || [];
    outEdges[e.from].push(e.to);
  });
  const queue = nodes.filter(n => inDegree[n.id] === 0).map(n => n.id);
  const nodeLevel = {};
  queue.forEach(id => nodeLevel[id] = 0);
  const tempInDegree = {
    ...inDegree
  };
  while (queue.length > 0) {
    const current = queue.shift();
    const currentLevel = nodeLevel[current];
    (outEdges[current] || []).forEach(next => {
      nodeLevel[next] = Math.max(nodeLevel[next] || 0, currentLevel + 1);
      tempInDegree[next]--;
      if (tempInDegree[next] === 0) queue.push(next);
    });
  }
  const levels = {};
  nodes.forEach(n => {
    const level = nodeLevel[n.id] || 0;
    levels[level] = levels[level] || [];
    levels[level].push(n);
  });
  const nodePositions = {};
  const numLevels = Object.keys(levels).length;
  const maxNodesInLevel = Math.max(...Object.values(levels).map(l => l.length), 1);
  const canvasHeight = Math.max(maxNodesInLevel * (nodeHeight + verticalGap) + 60, 180);
  const canvasWidth = numLevels * (nodeWidth + horizontalGap) + 80;
  Object.entries(levels).forEach(([level, levelNodes]) => {
    const levelNum = parseInt(level);
    const x = 40 + levelNum * (nodeWidth + horizontalGap);
    const totalHeight = levelNodes.length * nodeHeight + (levelNodes.length - 1) * verticalGap;
    const startY = (canvasHeight - totalHeight) / 2;
    levelNodes.forEach((node, index) => {
      nodePositions[node.id] = {
        x,
        y: startY + index * (nodeHeight + verticalGap)
      };
    });
  });
  const edgePaths = edges.map((edge, i) => {
    const from = nodePositions[edge.from];
    const to = nodePositions[edge.to];
    if (!from || !to) return null;
    const startX = from.x + nodeWidth;
    const startY = from.y + nodeHeight / 2;
    const endX = to.x - 6;
    const endY = to.y + nodeHeight / 2;
    const dx = endX - startX;
    const controlOffset = Math.min(dx * 0.4, 40);
    return {
      key: `edge-${i}`,
      path: `M ${startX} ${startY} C ${startX + controlOffset} ${startY}, ${endX - controlOffset} ${endY}, ${endX} ${endY}`
    };
  }).filter(Boolean);
  const uniqueId = `graph-${Math.random().toString(36).substring(2, 11)}`;
  const containerRef = useRef(null);
  const [scale, setScale] = useState(1);
  const [position, setPosition] = useState({
    x: 0,
    y: 0
  });
  const [isDragging, setIsDragging] = useState(false);
  const [dragStart, setDragStart] = useState({
    x: 0,
    y: 0
  });
  const handleWheel = e => {
    e.preventDefault();
    const delta = e.deltaY > 0 ? -0.1 : 0.1;
    setScale(s => Math.max(0.4, Math.min(2, s + delta)));
  };
  const handleMouseDown = e => {
    if (e.target.closest('button')) return;
    setIsDragging(true);
    setDragStart({
      x: e.clientX - position.x,
      y: e.clientY - position.y
    });
  };
  const handleMouseMove = e => {
    if (!isDragging) return;
    setPosition({
      x: e.clientX - dragStart.x,
      y: e.clientY - dragStart.y
    });
  };
  const handleMouseUp = () => setIsDragging(false);
  const handleFit = () => {
    const container = containerRef.current;
    if (container) {
      const containerWidth = container.clientWidth - 40;
      const containerHeight = container.clientHeight - 80;
      const scaleX = containerWidth / canvasWidth;
      const scaleY = containerHeight / canvasHeight;
      const fitScale = Math.min(scaleX, scaleY, 1);
      setScale(Math.max(0.4, fitScale));
    } else {
      setScale(1);
    }
    setPosition({
      x: 0,
      y: 0
    });
  };
  const handleZoomIn = () => setScale(s => Math.min(s + 0.15, 2));
  const handleZoomOut = () => setScale(s => Math.max(s - 0.15, 0.4));
  useEffect(() => {
    const container = containerRef.current;
    if (container) {
      container.addEventListener('wheel', handleWheel, {
        passive: false
      });
      return () => container.removeEventListener('wheel', handleWheel);
    }
  }, []);
  useEffect(() => {
    const timer = setTimeout(() => {
      const container = containerRef.current;
      if (container) {
        const containerWidth = container.clientWidth - 40;
        const containerHeight = container.clientHeight - 80;
        const scaleX = containerWidth / canvasWidth;
        const scaleY = containerHeight / canvasHeight;
        const fitScale = Math.min(scaleX, scaleY, 1);
        setScale(Math.max(0.4, fitScale));
      }
    }, 0);
    return () => clearTimeout(timer);
  }, [canvasWidth, canvasHeight]);
  const containerHeight = Math.max(300, Math.min(canvasHeight + 80, 420));
  return <div style={{
    marginTop: '16px',
    marginBottom: '24px'
  }}>
      {title && <div style={{
    fontSize: '13px',
    fontWeight: '500',
    color: '#64748b',
    marginBottom: '8px',
    fontFamily: 'system-ui, -apple-system, sans-serif'
  }}>
          {title}
        </div>}
      <div ref={containerRef} onMouseDown={handleMouseDown} onMouseMove={handleMouseMove} onMouseUp={handleMouseUp} onMouseLeave={handleMouseUp} style={{
    position: 'relative',
    border: '1px solid #e5e7eb',
    borderRadius: '12px',
    backgroundColor: '#f9fafb',
    overflow: 'hidden',
    height: containerHeight,
    cursor: isDragging ? 'grabbing' : 'grab',
    userSelect: 'none'
  }}>
        {}
        <div style={{
    position: 'absolute',
    top: '12px',
    right: '12px',
    display: 'flex',
    gap: '6px',
    zIndex: 10
  }}>
          <button onClick={handleZoomIn} style={{
    width: '32px',
    height: '32px',
    border: '1px solid #d1d5db',
    borderRadius: '8px',
    backgroundColor: 'white',
    cursor: 'pointer',
    display: 'flex',
    alignItems: 'center',
    justifyContent: 'center',
    fontSize: '18px',
    color: '#6b7280',
    fontWeight: '400',
    boxShadow: '0 1px 2px rgba(0,0,0,0.05)',
    transition: 'background-color 0.15s'
  }} onMouseOver={e => e.target.style.backgroundColor = '#f3f4f6'} onMouseOut={e => e.target.style.backgroundColor = 'white'}>+</button>
          <button onClick={handleZoomOut} style={{
    width: '32px',
    height: '32px',
    border: '1px solid #d1d5db',
    borderRadius: '8px',
    backgroundColor: 'white',
    cursor: 'pointer',
    display: 'flex',
    alignItems: 'center',
    justifyContent: 'center',
    fontSize: '18px',
    color: '#6b7280',
    fontWeight: '400',
    boxShadow: '0 1px 2px rgba(0,0,0,0.05)',
    transition: 'background-color 0.15s'
  }} onMouseOver={e => e.target.style.backgroundColor = '#f3f4f6'} onMouseOut={e => e.target.style.backgroundColor = 'white'}>−</button>
          <button onClick={handleFit} style={{
    height: '32px',
    padding: '0 12px',
    border: '1px solid #d1d5db',
    borderRadius: '8px',
    backgroundColor: 'white',
    cursor: 'pointer',
    fontSize: '12px',
    fontWeight: '500',
    color: '#6b7280',
    fontFamily: 'system-ui, -apple-system, sans-serif',
    boxShadow: '0 1px 2px rgba(0,0,0,0.05)',
    transition: 'background-color 0.15s'
  }} onMouseOver={e => e.target.style.backgroundColor = '#f3f4f6'} onMouseOut={e => e.target.style.backgroundColor = 'white'}>Reset</button>
        </div>

        {}
        <div style={{
    transform: `translate(calc(-50% + ${position.x}px), calc(-50% + ${position.y}px)) scale(${scale})`,
    transformOrigin: 'center center',
    width: canvasWidth,
    height: canvasHeight,
    position: 'absolute',
    left: '50%',
    top: '50%'
  }}>
          {}
          <svg style={{
    position: 'absolute',
    top: 0,
    left: 0,
    width: canvasWidth,
    height: canvasHeight,
    pointerEvents: 'none',
    overflow: 'visible'
  }}>
            <defs>
              <marker id={`${uniqueId}-arrow`} markerWidth="8" markerHeight="8" refX="6" refY="4" orient="auto" markerUnits="strokeWidth">
                <path d="M0,1 L6,4 L0,7 L2,4 Z" fill="#9ca3af" />
              </marker>
            </defs>
            {edgePaths.map(edge => <path key={edge.key} d={edge.path} fill="none" stroke="#d1d5db" strokeWidth="2" strokeLinecap="round" markerEnd={`url(#${uniqueId}-arrow)`} />)}
          </svg>

          {}
          {nodes.map(node => {
    const pos = nodePositions[node.id];
    if (!pos) return null;
    const colors = nodeColors[node.type] || nodeColors.expression;
    const isHighlighted = highlighted === node.id;
    return <div key={node.id} style={{
      position: 'absolute',
      left: pos.x,
      top: pos.y,
      width: nodeWidth,
      height: nodeHeight,
      backgroundColor: isHighlighted ? '#fef2f2' : colors.bg,
      border: `1.5px solid ${isHighlighted ? '#f87171' : colors.border}`,
      borderRadius: '10px',
      display: 'flex',
      flexDirection: 'column',
      alignItems: 'center',
      justifyContent: 'center',
      boxShadow: isHighlighted ? '0 0 0 2px rgba(248, 113, 113, 0.25)' : '0 1px 2px rgba(0,0,0,0.04)',
      cursor: 'default'
    }}>
                <div style={{
      fontSize: '14px',
      marginBottom: '3px',
      color: isHighlighted ? '#ef4444' : colors.text,
      opacity: 0.85
    }}>
                  {colors.icon}
                </div>
                <div style={{
      fontSize: '11px',
      fontWeight: '500',
      color: isHighlighted ? '#dc2626' : colors.text,
      textAlign: 'center',
      padding: '0 8px',
      fontFamily: 'system-ui, -apple-system, sans-serif',
      lineHeight: '1.35',
      maxWidth: '100%'
    }}>
                  {node.label}
                </div>
              </div>;
  })}
        </div>

        {}
        <div style={{
    position: 'absolute',
    bottom: '12px',
    left: '12px',
    display: 'flex',
    gap: '14px',
    padding: '8px 12px',
    fontSize: '11px',
    color: '#6b7280',
    fontFamily: 'system-ui, -apple-system, sans-serif',
    backgroundColor: 'white',
    borderRadius: '8px',
    border: '1px solid #e5e7eb',
    boxShadow: '0 1px 2px rgba(0,0,0,0.05)'
  }}>
          {Object.entries(nodeColors).map(([type, colors]) => <div key={type} style={{
    display: 'flex',
    alignItems: 'center',
    gap: '5px'
  }}>
              <div style={{
    width: '16px',
    height: '16px',
    backgroundColor: colors.bg,
    border: `1px solid ${colors.border}`,
    borderRadius: '4px',
    display: 'flex',
    alignItems: 'center',
    justifyContent: 'center',
    fontSize: '9px',
    color: colors.text
  }}>
                {colors.icon}
              </div>
              <span style={{
    textTransform: 'capitalize'
  }}>{type}</span>
            </div>)}
        </div>
      </div>
    </div>;
};

export const ExpressionViz = ({entries = []}) => {
  const allCells = entries.flatMap((entry, i) => [<div key={`key-${i}`} style={{
    backgroundColor: '#dbeafe',
    color: '#1e40af',
    padding: '12px 16px',
    borderRadius: '8px',
    fontFamily: 'ui-monospace, monospace',
    fontSize: '14px',
    fontWeight: '500'
  }}>
      {entry.key}
    </div>, <div key={`val-${i}`} style={{
    backgroundColor: '#ffffff',
    color: '#334155',
    padding: '12px 16px',
    borderRadius: '8px',
    fontFamily: 'ui-monospace, monospace',
    fontSize: '14px',
    border: '1px solid #e2e8f0'
  }}>
      {entry.value}
    </div>]);
  return <div style={{
    backgroundColor: '#f1f5f9',
    borderRadius: '12px',
    padding: '20px',
    margin: '16px 0'
  }}>
      <div style={{
    display: 'grid',
    gridTemplateColumns: 'auto 1fr',
    gap: '8px'
  }}>
        {allCells}
      </div>
    </div>;
};

export const DecisionTableViz = ({inputs = [], outputs = [], rows = []}) => {
  const colCount = inputs.length + outputs.length;
  const getFieldDisplay = field => !field || field === '-' ? '-' : field;
  const allCells = rows.flatMap((row, ri) => [...inputs.map((input, i) => <div key={`c-${ri}-in-${i}`} style={{
    backgroundColor: '#ffffff',
    padding: '12px 16px',
    borderRadius: '8px',
    fontFamily: 'ui-monospace, monospace',
    fontSize: '14px',
    border: '1px solid #e2e8f0'
  }}>
        {row[input.field] ?? ''}
      </div>), ...outputs.map((output, i) => <div key={`c-${ri}-out-${i}`} style={{
    backgroundColor: '#ffffff',
    padding: '12px 16px',
    borderRadius: '8px',
    fontFamily: 'ui-monospace, monospace',
    fontSize: '14px',
    border: '1px solid #e2e8f0'
  }}>
        {row[output.field] ?? ''}
      </div>)]);
  return <div style={{
    backgroundColor: '#f1f5f9',
    borderRadius: '12px',
    padding: '20px',
    overflowX: 'auto',
    margin: '16px 0'
  }}>
      <div style={{
    display: 'grid',
    gridTemplateColumns: `repeat(${colCount}, 1fr)`,
    gap: '8px'
  }}>
        {}
        <div key="group-inputs" style={{
    gridColumn: `span ${inputs.length}`,
    padding: '8px 16px',
    fontWeight: '600',
    fontSize: '13px',
    color: '#374151',
    borderBottom: '2px solid #e2e8f0'
  }}>
          Inputs
        </div>
        <div key="group-outputs" style={{
    gridColumn: `span ${outputs.length}`,
    padding: '8px 16px',
    fontWeight: '600',
    fontSize: '13px',
    color: '#374151',
    borderBottom: '2px solid #e2e8f0'
  }}>
          Outputs
        </div>

        {}
        {inputs.map((input, i) => <div key={`h-in-${i}`} style={{
    backgroundColor: '#f0abfc',
    color: '#581c87',
    padding: '12px 16px',
    borderRadius: '8px',
    fontWeight: '500'
  }}>
            <div>{input.name}</div>
            <div style={{
    display: 'inline-block',
    marginTop: '6px',
    padding: '2px 8px',
    fontSize: '12px',
    fontWeight: '400',
    backgroundColor: 'rgba(88, 28, 135, 0.15)',
    borderRadius: '4px',
    fontFamily: 'ui-monospace, monospace'
  }}>
              {getFieldDisplay(input.field)}
            </div>
          </div>)}
        {outputs.map((output, i) => <div key={`h-out-${i}`} style={{
    backgroundColor: '#a5b4fc',
    color: '#312e81',
    padding: '12px 16px',
    borderRadius: '8px',
    fontWeight: '500'
  }}>
            <div>{output.name}</div>
            <div style={{
    display: 'inline-block',
    marginTop: '6px',
    padding: '2px 8px',
    fontSize: '12px',
    fontWeight: '400',
    backgroundColor: 'rgba(49, 46, 129, 0.15)',
    borderRadius: '4px',
    fontFamily: 'ui-monospace, monospace'
  }}>
              {getFieldDisplay(output.field)}
            </div>
          </div>)}

        {}
        {allCells}
      </div>
    </div>;
};

This guide shows how to build risk scoring models that combine multiple factors into an overall risk assessment.

## What you'll build

A risk scoring decision that:

* Evaluates multiple risk factors independently
* Weights factors based on importance
* Produces a composite risk score
* Classifies into risk categories

<Note>
  Want to skip ahead? <a href="/downloads/risk-scoring.json" download>Download the completed decision</a> and import it directly.
</Note>

### Decision flow

<DecisionGraphViz
  title="Fraud Risk Scoring"
  nodes={[
{ id: "input", type: "input", label: "Transaction Data" },
{ id: "transactionRisk", type: "table", label: "Transaction Risk" },
{ id: "customerRisk", type: "table", label: "Customer Risk" },
{ id: "deviceRisk", type: "table", label: "Device Risk" },
{ id: "calculate", type: "expression", label: "Calculate Score" },
{ id: "classify", type: "table", label: "Risk Classification" }
]}
  edges={[
{ from: "input", to: "transactionRisk" },
{ from: "input", to: "customerRisk" },
{ from: "input", to: "deviceRisk" },
{ from: "transactionRisk", to: "calculate" },
{ from: "customerRisk", to: "calculate" },
{ from: "deviceRisk", to: "calculate" },
{ from: "calculate", to: "classify" }
]}
/>

## Example: Fraud risk scoring

### Input data

```json theme={null}
{
  "transaction": {
    "amount": 1500,
    "currency": "USD",
    "country": "NG",
    "isFirstPurchase": true
  },
  "customer": {
    "accountAgeDays": 5,
    "verificationLevel": "email_only",
    "previousTransactions": 0
  },
  "device": {
    "isKnown": false,
    "vpnDetected": true,
    "riskScore": 75
  }
}
```

## Step 1: Transaction risk factors

Score transaction-level signals:

<DecisionTableViz
  inputs={[
{ name: "Amount", field: "transaction.amount" },
{ name: "Country", field: "transaction.country" }
]}
  outputs={[
{ name: "Amount Score", field: "amountScore" },
{ name: "Country Score", field: "countryScore" }
]}
  rows={[
{ "transaction.amount": ">= 5000", "transaction.country": "", amountScore: "30", countryScore: "" },
{ "transaction.amount": "[1000..4999]", "transaction.country": "", amountScore: "20", countryScore: "" },
{ "transaction.amount": "[500..999]", "transaction.country": "", amountScore: "10", countryScore: "" },
{ "transaction.amount": "< 500", "transaction.country": "", amountScore: "5", countryScore: "" },
{ "transaction.amount": "", "transaction.country": '"NG", "RU", "CN"', amountScore: "", countryScore: "25" },
{ "transaction.amount": "", "transaction.country": '"BR", "IN", "ID"', amountScore: "", countryScore: "15" },
{ "transaction.amount": "", "transaction.country": "", amountScore: "", countryScore: "5" }
]}
/>

## Step 2: Customer risk factors

Evaluate customer signals:

<DecisionTableViz
  inputs={[
{ name: "Account Age (days)", field: "customer.accountAgeDays" },
{ name: "Verification Level", field: "customer.verificationLevel" },
{ name: "Previous Transactions", field: "customer.previousTransactions" }
]}
  outputs={[
{ name: "Age Score", field: "ageScore" },
{ name: "Verification Score", field: "verifyScore" },
{ name: "History Score", field: "historyScore" }
]}
  rows={[
{ "customer.accountAgeDays": "< 7", "customer.verificationLevel": "", "customer.previousTransactions": "", ageScore: "25", verifyScore: "", historyScore: "" },
{ "customer.accountAgeDays": "[7..30]", "customer.verificationLevel": "", "customer.previousTransactions": "", ageScore: "15", verifyScore: "", historyScore: "" },
{ "customer.accountAgeDays": "> 30", "customer.verificationLevel": "", "customer.previousTransactions": "", ageScore: "5", verifyScore: "", historyScore: "" },
{ "customer.accountAgeDays": "", "customer.verificationLevel": '"none"', "customer.previousTransactions": "", ageScore: "", verifyScore: "30", historyScore: "" },
{ "customer.accountAgeDays": "", "customer.verificationLevel": '"email_only"', "customer.previousTransactions": "", ageScore: "", verifyScore: "20", historyScore: "" },
{ "customer.accountAgeDays": "", "customer.verificationLevel": '"phone"', "customer.previousTransactions": "", ageScore: "", verifyScore: "10", historyScore: "" },
{ "customer.accountAgeDays": "", "customer.verificationLevel": '"id_verified"', "customer.previousTransactions": "", ageScore: "", verifyScore: "0", historyScore: "" },
{ "customer.accountAgeDays": "", "customer.verificationLevel": "", "customer.previousTransactions": "0", ageScore: "", verifyScore: "", historyScore: "20" },
{ "customer.accountAgeDays": "", "customer.verificationLevel": "", "customer.previousTransactions": "[1..5]", ageScore: "", verifyScore: "", historyScore: "10" },
{ "customer.accountAgeDays": "", "customer.verificationLevel": "", "customer.previousTransactions": "> 5", ageScore: "", verifyScore: "", historyScore: "0" }
]}
/>

## Step 3: Device risk factors

<DecisionTableViz
  inputs={[
{ name: "Known Device", field: "device.isKnown" },
{ name: "VPN Detected", field: "device.vpnDetected" }
]}
  outputs={[
{ name: "Device Score", field: "deviceScore" },
{ name: "VPN Score", field: "vpnScore" }
]}
  rows={[
{ "device.isKnown": "false", "device.vpnDetected": "", deviceScore: "20", vpnScore: "" },
{ "device.isKnown": "true", "device.vpnDetected": "", deviceScore: "0", vpnScore: "" },
{ "device.isKnown": "", "device.vpnDetected": "true", deviceScore: "", vpnScore: "15" },
{ "device.isKnown": "", "device.vpnDetected": "false", deviceScore: "", vpnScore: "0" }
]}
/>

## Step 4: Calculate composite score

Combine all factors with weights:

<ExpressionViz
  entries={[
{ key: "transactionScore", value: "TransactionRisk.amountScore + TransactionRisk.countryScore" },
{ key: "customerScore", value: "CustomerRisk.ageScore + CustomerRisk.verifyScore + CustomerRisk.historyScore" },
{ key: "deviceScore", value: "DeviceRisk.deviceScore + DeviceRisk.vpnScore + (device.riskScore / 5)" },
{ key: "rawScore", value: "transactionScore + customerScore + deviceScore" },
{ key: "normalizedScore", value: "min([round(rawScore), 100])" }
]}
/>

## Step 5: Risk classification

Map score to action:

<DecisionTableViz
  inputs={[
{ name: "Risk Score", field: "score" }
]}
  outputs={[
{ name: "Risk Level", field: "level" },
{ name: "Action", field: "action" }
]}
  rows={[
{ score: ">= 80", level: '"critical"', action: '"block"' },
{ score: "[60..79]", level: '"high"', action: '"manual_review"' },
{ score: "[40..59]", level: '"medium"', action: '"step_up_auth"' },
{ score: "[20..39]", level: '"low"', action: '"monitor"' },
{ score: "< 20", level: '"minimal"', action: '"allow"' }
]}
/>

## Output structure

```json theme={null}
{
  "riskScore": 85,
  "riskLevel": "critical",
  "action": "block",
  "factors": {
    "transaction": 45,
    "customer": 65,
    "device": 50
  },
  "signals": [
    "High-risk country",
    "New account",
    "Unknown device",
    "VPN detected"
  ]
}
```

## Variations

### Credit risk scoring

<DecisionTableViz
  inputs={[
{ name: "Payment History", field: "history" },
{ name: "Utilization", field: "util" },
{ name: "Account Age", field: "age" }
]}
  outputs={[
{ name: "History Points", field: "historyPts" },
{ name: "Util Points", field: "utilPts" },
{ name: "Age Points", field: "agePts" }
]}
  rows={[
{ history: '"perfect"', util: "", age: "", historyPts: "35", utilPts: "", agePts: "" },
{ history: '"good"', util: "", age: "", historyPts: "25", utilPts: "", agePts: "" },
{ history: '"fair"', util: "", age: "", historyPts: "15", utilPts: "", agePts: "" },
{ history: "", util: "< 30", age: "", historyPts: "", utilPts: "30", agePts: "" },
{ history: "", util: "[30..50]", age: "", historyPts: "", utilPts: "20", agePts: "" },
{ history: "", util: "> 50", age: "", historyPts: "", utilPts: "10", agePts: "" },
{ history: "", util: "", age: "> 7", historyPts: "", utilPts: "", agePts: "15" },
{ history: "", util: "", age: "[2..7]", historyPts: "", utilPts: "", agePts: "10" },
{ history: "", util: "", age: "< 2", historyPts: "", utilPts: "", agePts: "5" }
]}
/>

## Best practices

**Document factor weights** — Make it clear why each factor has its weight.

**Normalize scores** — Use consistent 0-100 scales across factors.

**Include signal details** — Output which factors contributed to the score.

**Tune thresholds** — Adjust classification thresholds based on false positive rates.
