Module 6: Decision Matrix for AI Engineers

Capsule 03: Weights, Scoring, and Matrix Methodology

🎯 Capsule objective

Master the quantitative mechanics of the decision matrix: how to assign weights that reflect real priorities, how to score providers consistently, and how to normalize results so the comparison is fair.

By the end of this capsule:

  • ✅ You'll assign weights using techniques that eliminate bias
  • ✅ You'll build objective scoring rubrics per criterion
  • ✅ You'll normalize scores to compare different dimensions
  • ✅ You'll implement the complete scoring formula in Python

Estimated time: 25-35 minutes


Capsule description

In the previous capsule you defined WHAT to evaluate. Now you need to define HOW to evaluate it. The difference between a solid decision and a decision disguised as analysis is in the scoring methodology. If you assign arbitrary weights or score "by eye", your sophisticated matrix produces exactly the same result as choosing at random — but with the false confidence of a number.

This capsule teaches you three professional techniques for assigning weights (fixed distribution, pairwise comparison, and stack ranking), two scoring scales with concrete rubrics for each level, and the normalization process that lets you compare apples with oranges (latency in milliseconds vs cost in dollars vs operational complexity).

In the end, you'll have a complete ScoringEngine in Python that you can feed with your criteria and providers to get a reproducible quantitative ranking. It's not magic — it's discipline.


Why weights matter more than scores

Consider this example:

# Same provider, different weights = different winner

provider_scores = {
    "pinecone": {"latency": 0.9, "cost": 0.5, "ops_simplicity": 1.0},
    "qdrant_self": {"latency": 0.8, "cost": 0.9, "ops_simplicity": 0.4},
}

# Team A: startup without DevOps, speed matters
weights_startup = {"latency": 3, "cost": 4, "ops_simplicity": 5}

# Team B: company with an SRE, cost dominates
weights_enterprise = {"latency": 5, "cost": 5, "ops_simplicity": 2}

def weighted_score(scores: dict, weights: dict) -> float:
    total = sum(weights[k] * scores[k] for k in weights)
    max_possible = sum(weights.values())
    return round((total / max_possible) * 100, 1)

print("=== Team A (startup without DevOps) ===")
for name, scores in provider_scores.items():
    print(f"  {name}: {weighted_score(scores, weights_startup)}%")

print("\n=== Team B (enterprise with SRE) ===")
for name, scores in provider_scores.items():
    print(f"  {name}: {weighted_score(scores, weights_enterprise)}%")
=== Team A (startup without DevOps) ===
  pinecone: 80.8%
  qdrant_self: 66.7%

=== Team B (enterprise with SRE) ===
  pinecone: 75.0%
  qdrant_self: 77.5%

Same data, different conclusion. The weights determine the result more than the individual scores. That's why you need a rigorous process to assign them.


Technique 1: Fixed point distribution

Each stakeholder gets a fixed budget of points (for example, 100) and distributes them among the criteria. This forces real trade-offs:

def fixed_distribution(criteria: list[str], total_points: int = 100) -> dict:
    """
    Simulate the fixed point distribution.
    In practice, each stakeholder fills this in individually.
    """
    print(f"Distribute {total_points} points among {len(criteria)} criteria.")
    print("Rule: the sum MUST be exactly {total_points}.")
    print("Criteria:", ", ".join(criteria))

    return {}  # In practice, filled in manually


def aggregate_distributions(distributions: list[dict]) -> dict:
    """Average the distributions of multiple stakeholders."""
    all_keys = set()
    for d in distributions:
        all_keys.update(d.keys())

    aggregated = {}
    for key in all_keys:
        values = [d.get(key, 0) for d in distributions]
        aggregated[key] = round(sum(values) / len(values), 1)

    return aggregated


# Example: 3 stakeholders distribute 100 points
cto_weights = {
    "latency": 25, "scale": 25, "cost": 15,
    "ops_simplicity": 10, "sdk_quality": 15, "compliance": 10
}
pm_weights = {
    "latency": 15, "scale": 10, "cost": 30,
    "ops_simplicity": 20, "sdk_quality": 5, "compliance": 20
}
dev_weights = {
    "latency": 20, "scale": 15, "cost": 10,
    "ops_simplicity": 15, "sdk_quality": 30, "compliance": 10
}

# Validation: each distribution must add up to 100
for name, weights in [("CTO", cto_weights), ("PM", pm_weights), ("Dev", dev_weights)]:
    total = sum(weights.values())
    status = "✅" if total == 100 else f"❌ (sum {total})"
    print(f"{name}: {status}")

# Aggregation
final_weights = aggregate_distributions([cto_weights, pm_weights, dev_weights])
print("\nFinal weights (average):")
for criterion, weight in sorted(final_weights.items(), key=lambda x: -x[1]):
    print(f"  {criterion}: {weight}")
CTO: ✅
PM: ✅
Dev: ✅

Final weights (average):
  latency: 20.0
  cost: 18.3
  sdk_quality: 16.7
  scale: 16.7
  ops_simplicity: 15.0
  compliance: 13.3

Advantage: Forces real trade-offs (you can't say "everything is important"). Disadvantage: Doesn't capture the relative intensity between pairs of criteria.


Technique 2: Pairwise comparison

Compare each criterion against every other criterion and decide which is more important. The count of "wins" determines the weight:

from itertools import combinations


def pairwise_comparison(criteria: list[str], preferences: dict[tuple, str]) -> dict:
    """
    Calculate weights based on pairwise comparison.

    Args:
        criteria: List of criterion names
        preferences: Dict of (criterion_a, criterion_b) -> winner
    """
    wins = {c: 0 for c in criteria}

    for (a, b), winner in preferences.items():
        wins[winner] += 1

    total_wins = sum(wins.values())
    if total_wins == 0:
        return {c: 1.0 / len(criteria) for c in criteria}

    weights = {c: round(w / total_wins, 3) for c, w in wins.items()}
    return weights


criteria = ["latency", "cost", "ops", "sdk", "scale", "compliance"]

# For each pair, which is more important for YOUR project?
preferences = {
    ("latency", "cost"): "latency",
    ("latency", "ops"): "latency",
    ("latency", "sdk"): "latency",
    ("latency", "scale"): "scale",
    ("latency", "compliance"): "latency",
    ("cost", "ops"): "cost",
    ("cost", "sdk"): "cost",
    ("cost", "scale"): "scale",
    ("cost", "compliance"): "compliance",
    ("ops", "sdk"): "ops",
    ("ops", "scale"): "scale",
    ("ops", "compliance"): "compliance",
    ("sdk", "scale"): "scale",
    ("sdk", "compliance"): "sdk",
    ("scale", "compliance"): "scale",
}

weights = pairwise_comparison(criteria, preferences)
print("Weights by pairwise comparison:")
for criterion, weight in sorted(weights.items(), key=lambda x: -x[1]):
    print(f"  {criterion}: {weight:.1%}")
Weights by pairwise comparison:
  scale: 33.3%
  latency: 26.7%
  cost: 13.3%
  compliance: 13.3%
  ops: 6.7%
  sdk: 6.7%

Advantage: More intuitive ("what matters more, A or B?") and produces clear rankings. Disadvantage: With N criteria, you need N×(N-1)/2 comparisons (15 for 6 criteria).


Technique 3: Stack ranking

The simplest: order all criteria from most to least important and assign decreasing weights automatically:

def stack_rank_weights(ranked_criteria: list[str], method: str = "linear") -> dict:
    """
    Assign weights based on ordinal ranking.

    Args:
        ranked_criteria: Ordered list (most important first)
        method: 'linear' (N, N-1, ..., 1) or 'geometric' (2^N, 2^(N-1), ...)
    """
    n = len(ranked_criteria)

    if method == "linear":
        raw_weights = {c: n - i for i, c in enumerate(ranked_criteria)}
    elif method == "geometric":
        raw_weights = {c: 2 ** (n - 1 - i) for i, c in enumerate(ranked_criteria)}
    else:
        raise ValueError(f"Unknown method: {method}")

    total = sum(raw_weights.values())
    normalized = {c: round(w / total, 3) for c, w in raw_weights.items()}
    return normalized


my_ranking = ["scale", "latency", "cost", "ops", "compliance", "sdk"]

print("Stack rank - Linear:")
linear = stack_rank_weights(my_ranking, "linear")
for c, w in linear.items():
    print(f"  {c}: {w:.1%}")

print("\nStack rank - Geometric:")
geometric = stack_rank_weights(my_ranking, "geometric")
for c, w in geometric.items():
    print(f"  {c}: {w:.1%}")
Stack rank - Linear:
  scale: 28.6%
  latency: 23.8%
  cost: 19.0%
  ops: 14.3%
  compliance: 9.5%
  sdk: 4.8%

Stack rank - Geometric:
  scale: 50.8%
  latency: 25.4%
  cost: 12.7%
  ops: 6.3%
  compliance: 3.2%
  sdk: 1.6%

Linear distributes weights gradually. Geometric drastically amplifies the difference between the first and last criterion. Use geometric only when your #1 criterion completely dominates the decision.


Scoring scales: concrete rubrics

A score without a rubric is an opinion in a numerical disguise. Define exactly what each level means:

5-level scale (recommended)

scoring_rubric = {
    1.0: {
        "label": "Fully meets",
        "definition": "Exceeds the ideal threshold. No relevant trade-offs.",
        "evidence_required": "Benchmark/docs proving the ideal threshold is met"
    },
    0.75: {
        "label": "Meets well",
        "definition": "Meets the minimum threshold and approaches the ideal. Minor trade-off.",
        "evidence_required": "Benchmark/docs + a note on the trade-off"
    },
    0.5: {
        "label": "Partial compliance",
        "definition": "Meets the minimum threshold but far from the ideal. Significant trade-off.",
        "evidence_required": "Document the gap and a mitigation plan"
    },
    0.25: {
        "label": "Weak compliance",
        "definition": "Doesn't meet the minimum threshold but is close. High risk.",
        "evidence_required": "Document the risk and the necessary workaround"
    },
    0.0: {
        "label": "Doesn't meet",
        "definition": "Doesn't meet the minimum threshold. No viable mitigation plan.",
        "evidence_required": "Mark as a deal-breaker if the criterion is a MUST"
    }
}

for score, details in scoring_rubric.items():
    print(f"\n{score}{details['label']}")
    print(f"  Definition: {details['definition']}")
    print(f"  Evidence: {details['evidence_required']}")

Dimension-specific rubrics

The meaning of "0.75" depends on the criterion. Here are concrete rubrics:

dimension_rubrics = {
    "latency_p95": {
        1.0: "< 100ms (exceeds the real-time expectation)",
        0.75: "100-250ms (good for chatbots/search)",
        0.5: "250-500ms (acceptable for batch/internal)",
        0.25: "500ms-1s (problematic for UX)",
        0.0: "> 1s (unacceptable for production)"
    },
    "cost_monthly": {
        1.0: "< 50% of the budget (ample margin)",
        0.75: "50-80% of the budget (viable)",
        0.5: "80-100% of the budget (no margin)",
        0.25: "100-150% of the budget (requires negotiation)",
        0.0: "> 150% of the budget (out of range)"
    },
    "ops_complexity": {
        1.0: "Managed, < 2h/month maintenance",
        0.75: "Semi-managed, 2-5h/month",
        0.5: "Simple self-hosted, 5-10h/month",
        0.25: "Self-hosted cluster, 10-20h/month",
        0.0: "Dedicated operations, > 20h/month or SRE required"
    },
    "sdk_quality": {
        1.0: "Mature SDK, type hints, async, testing mode, excellent docs",
        0.75: "Working SDK, good docs, minor issues",
        0.5: "Basic SDK, incomplete docs, workarounds needed",
        0.25: "Immature SDK, frequent bugs, minimal docs",
        0.0: "No SDK in your language or an abandoned SDK"
    },
    "community": {
        1.0: "> 10K stars, active, SO coverage, complete integrations",
        0.75: "5-10K stars, active, main integrations",
        0.5: "1-5K stars, moderate activity, partial integrations",
        0.25: "< 1K stars, low activity, few integrations",
        0.0: "New/abandoned project, no visible community"
    },
    "compliance": {
        1.0: "SOC2 + GDPR + HIPAA + CMEK + audit logs",
        0.75: "SOC2 + GDPR + encryption at rest",
        0.5: "Encryption at rest + basic auth",
        0.25: "Only basic auth, no certifications",
        0.0: "No encryption or certifications"
    }
}

print("=== Rubric: p95 latency ===")
for score, description in dimension_rubrics["latency_p95"].items():
    print(f"  {score}: {description}")

Normalization: comparing different dimensions

Latency is measured in milliseconds, cost in dollars, ops in hours. For the weights to work, you need to normalize everything to a common scale (0-1):

def normalize_score(
    raw_value: float,
    min_acceptable: float,
    ideal_value: float,
    lower_is_better: bool = True
) -> float:
    """
    Normalize a raw value to a 0-1 scale.

    Args:
        raw_value: The provider's measured value
        min_acceptable: Minimum acceptable threshold
        ideal_value: Ideal value (1.0)
        lower_is_better: True for latency/cost, False for throughput/recall
    """
    if lower_is_better:
        if raw_value <= ideal_value:
            return 1.0
        elif raw_value >= min_acceptable:
            return 0.25
        else:
            range_size = min_acceptable - ideal_value
            if range_size == 0:
                return 1.0 if raw_value <= ideal_value else 0.0
            normalized = 1.0 - ((raw_value - ideal_value) / range_size)
            return max(0.0, min(1.0, round(normalized, 2)))
    else:
        if raw_value >= ideal_value:
            return 1.0
        elif raw_value <= min_acceptable:
            return 0.25
        else:
            range_size = ideal_value - min_acceptable
            if range_size == 0:
                return 1.0 if raw_value >= ideal_value else 0.0
            normalized = (raw_value - min_acceptable) / range_size
            return max(0.0, min(1.0, round(normalized, 2)))


# Normalization examples
print("=== Latency normalization (lower is better) ===")
latencies = [50, 100, 200, 350, 500, 800]
for lat in latencies:
    score = normalize_score(lat, min_acceptable=500, ideal_value=100, lower_is_better=True)
    print(f"  {lat}ms → {score:.2f}")

print("\n=== Recall normalization (higher is better) ===")
recalls = [0.80, 0.90, 0.95, 0.98, 1.0]
for recall in recalls:
    score = normalize_score(recall, min_acceptable=0.90, ideal_value=0.98, lower_is_better=False)
    print(f"  {recall:.0%}{score:.2f}")
=== Latency normalization (lower is better) ===
  50ms → 1.00
  100ms → 1.00
  200ms → 0.75
  350ms → 0.38
  500ms → 0.25
  800ms → 0.25

=== Recall normalization (higher is better) ===
  80% → 0.25
  90% → 0.25
  95% → 0.62
  98% → 1.00
  100% → 1.00

ScoringEngine: complete implementation

Here's the class that integrates weights, scoring, and normalization:

from dataclasses import dataclass, field


@dataclass
class CriterionConfig:
    name: str
    weight: float
    min_acceptable: float
    ideal_value: float
    lower_is_better: bool = True
    unit: str = ""


@dataclass
class ProviderScore:
    name: str
    raw_scores: dict[str, float] = field(default_factory=dict)
    normalized_scores: dict[str, float] = field(default_factory=dict)
    weighted_scores: dict[str, float] = field(default_factory=dict)
    total_score: float = 0.0
    total_percentage: float = 0.0


class ScoringEngine:
    def __init__(self, criteria: list[CriterionConfig]):
        self.criteria = {c.name: c for c in criteria}
        self.providers: dict[str, ProviderScore] = {}

    def add_provider(self, name: str, raw_scores: dict[str, float]):
        provider = ProviderScore(name=name, raw_scores=raw_scores)

        for criterion_name, raw_value in raw_scores.items():
            config = self.criteria[criterion_name]

            normalized = normalize_score(
                raw_value,
                config.min_acceptable,
                config.ideal_value,
                config.lower_is_better
            )
            provider.normalized_scores[criterion_name] = normalized

            weighted = normalized * config.weight
            provider.weighted_scores[criterion_name] = round(weighted, 2)

        provider.total_score = sum(provider.weighted_scores.values())
        max_possible = sum(c.weight for c in self.criteria.values())
        provider.total_percentage = round(
            (provider.total_score / max_possible) * 100, 1
        )

        self.providers[name] = provider

    def ranking(self) -> list[ProviderScore]:
        return sorted(
            self.providers.values(),
            key=lambda p: p.total_score,
            reverse=True
        )

    def sensitivity_analysis(self, criterion_name: str, weight_range: list[float]) -> dict:
        """How does the ranking change if you change a criterion's weight?"""
        results = {}
        original_weight = self.criteria[criterion_name].weight

        for new_weight in weight_range:
            self.criteria[criterion_name].weight = new_weight
            for name, provider in self.providers.items():
                raw = provider.raw_scores
                self.add_provider(name, raw)

            ranking = [(p.name, p.total_percentage) for p in self.ranking()]
            results[new_weight] = ranking

        self.criteria[criterion_name].weight = original_weight
        for name, provider in self.providers.items():
            self.add_provider(name, provider.raw_scores)

        return results

    def report(self) -> str:
        lines = ["=== SCORING REPORT ===\n"]

        header = f"{'Criterion':<20} {'Weight':<6}"
        for p in self.ranking():
            header += f" {p.name:<15}"
        lines.append(header)
        lines.append("-" * len(header))

        for criterion_name, config in self.criteria.items():
            row = f"{criterion_name:<20} {config.weight:<6.1f}"
            for p in self.ranking():
                raw = p.raw_scores.get(criterion_name, 0)
                norm = p.normalized_scores.get(criterion_name, 0)
                row += f" {raw:>5.1f}{norm:.2f}    "
            lines.append(row)

        lines.append("-" * len(header))
        total_row = f"{'TOTAL':<20} {'':6}"
        for p in self.ranking():
            total_row += f" {p.total_percentage:>8.1f}%     "
        lines.append(total_row)

        return "\n".join(lines)


# Complete example
engine = ScoringEngine([
    CriterionConfig("latency_p95", weight=5.0, min_acceptable=500,
                    ideal_value=100, lower_is_better=True, unit="ms"),
    CriterionConfig("cost_monthly", weight=4.0, min_acceptable=500,
                    ideal_value=100, lower_is_better=True, unit="USD"),
    CriterionConfig("ops_hours", weight=4.0, min_acceptable=15,
                    ideal_value=2, lower_is_better=True, unit="h/month"),
    CriterionConfig("recall", weight=3.0, min_acceptable=0.90,
                    ideal_value=0.98, lower_is_better=False, unit="%"),
    CriterionConfig("sdk_score", weight=2.0, min_acceptable=0.5,
                    ideal_value=0.9, lower_is_better=False, unit="0-1"),
])

engine.add_provider("Pinecone", {
    "latency_p95": 120, "cost_monthly": 350,
    "ops_hours": 2, "recall": 0.96, "sdk_score": 0.85
})

engine.add_provider("Qdrant Cloud", {
    "latency_p95": 150, "cost_monthly": 200,
    "ops_hours": 3, "recall": 0.95, "sdk_score": 0.80
})

engine.add_provider("ChromaDB", {
    "latency_p95": 300, "cost_monthly": 60,
    "ops_hours": 10, "recall": 0.92, "sdk_score": 0.75
})

engine.add_provider("Weaviate Cloud", {
    "latency_p95": 180, "cost_monthly": 280,
    "ops_hours": 2, "recall": 0.94, "sdk_score": 0.70
})

print(engine.report())
print("\n=== RANKING ===")
for i, p in enumerate(engine.ranking(), 1):
    print(f"  #{i} {p.name}: {p.total_percentage}%")

Interpreting results

Don't take the result as an absolute verdict. Use these guidelines:

def interpret_score(percentage: float) -> dict:
    """Interpret a provider's final score."""
    if percentage >= 85:
        return {
            "verdict": "Excellent fit",
            "action": "Proceed with an immediate PoC",
            "confidence": "High",
            "risk": "Low"
        }
    elif percentage >= 70:
        return {
            "verdict": "Good fit with trade-offs",
            "action": "Proceed with a PoC, document mitigations",
            "confidence": "Medium-high",
            "risk": "Moderate — monitor the criteria where it scored low"
        }
    elif percentage >= 55:
        return {
            "verdict": "Partial fit",
            "action": "Only if there's no better alternative or a dominant constraint",
            "confidence": "Medium",
            "risk": "High — requires an explicit mitigation plan"
        }
    else:
        return {
            "verdict": "Not recommended",
            "action": "Discard except in exceptional circumstances",
            "confidence": "Low",
            "risk": "Very high"
        }

# Gap analysis: where does each provider lose points?
def gap_analysis(provider: ProviderScore, criteria: dict[str, CriterionConfig]) -> list[dict]:
    """Identify the criteria where a provider loses the most points."""
    gaps = []
    for name, config in criteria.items():
        normalized = provider.normalized_scores.get(name, 0)
        if normalized < 0.75:
            lost_points = (1.0 - normalized) * config.weight
            gaps.append({
                "criterion": name,
                "normalized_score": normalized,
                "weight": config.weight,
                "points_lost": round(lost_points, 2),
                "raw_value": provider.raw_scores.get(name, 0)
            })
    return sorted(gaps, key=lambda g: -g["points_lost"])

Sensitivity analysis

Does your result change if you adjust a weight? If so, your decision is fragile:

def sensitivity_check(engine: ScoringEngine, criterion: str) -> None:
    """Check whether the ranking is stable against weight changes."""
    print(f"\n=== Sensitivity: what happens if I change the weight of '{criterion}'? ===")

    results = engine.sensitivity_analysis(criterion, [1.0, 2.0, 3.0, 4.0, 5.0])
    for weight, ranking in results.items():
        leader = ranking[0]
        second = ranking[1]
        gap = leader[1] - second[1]
        stability = "🟢 stable" if gap > 5 else "🟡 close" if gap > 2 else "🔴 fragile"
        print(f"  Weight {weight}: #{1} {leader[0]} ({leader[1]}%) "
              f"vs #{2} {second[0]} ({second[1]}%) — gap: {gap:.1f}% {stability}")

# Example
sensitivity_check(engine, "cost_monthly")

If changing a weight from 3 to 5 changes the winner, your decision depends on that criterion. That's not wrong — but you must be aware of it.


Bias in scoring: how to detect and reduce it

bias_checklist = {
    "anchoring_bias": {
        "description": "The first provider you evaluated biases the others",
        "detection": "Did you score the first one higher on almost everything?",
        "mitigation": "Score all providers on one criterion before moving to the next"
    },
    "familiarity_bias": {
        "description": "You score the provider you already know higher",
        "detection": "Does the one you currently use win by a lot?",
        "mitigation": "Include a team member who has NOT used the current provider"
    },
    "recency_bias": {
        "description": "The latest blog post or tweet influences your score",
        "detection": "Did your evaluation change after reading an article?",
        "mitigation": "Use only evidence from official docs, benchmarks, and your own PoCs"
    },
    "halo_effect": {
        "description": "An excellent criterion inflates the others",
        "detection": "Does a provider have almost everything at 1.0?",
        "mitigation": "Each criterion is scored with its own rubric, not by 'general feeling'"
    },
    "sunk_cost_bias": {
        "description": "You already invested time in a provider and don't want to discard it",
        "detection": "Are you looking for justifications to keep the current one?",
        "mitigation": "Evaluate as if starting from scratch (greenfield analysis)"
    }
}

print("=== Bias checklist ===")
for bias, info in bias_checklist.items():
    print(f"\n⚠️ {bias}")
    print(f"  Description: {info['description']}")
    print(f"  Detection: {info['detection']}")
    print(f"  Mitigation: {info['mitigation']}")

🔧 Troubleshooting

Problem 1: "Everything comes out tied"

Symptom: Two or more providers have scores within 3-5% of each other.

Solution: Increase the resolution in the criteria that weigh the most. If "latency" has weight 5, subdivide it into latency_p50, latency_p95, latency_p99. Another option: add a "tiebreaker" criterion like migration ease or the team's previous experience.

Problem 2: "The result contradicts the team's intuition"

Symptom: The matrix's winner isn't the one the team "feels" should win.

Solution: Don't dismiss the intuition — check whether the weights reflect the real priorities. Sometimes intuition captures information that isn't in the matrix (a previous negative experience, a relationship with the vendor). If, after reviewing the weights, the numbers still contradict it, document the override and its justification.

Problem 3: "I don't have data to score objectively"

Symptom: You haven't done benchmarks or PoCs and you're scoring based on marketing.

Solution: Use a provisional score (mark it with ⚠️) based on documentation and public benchmarks. Plan a 2-3 day PoC for the top 2 providers to validate the provisional scores before deciding.

Problem 4: "Each stakeholder produces a completely different ranking"

Symptom: The CTO chooses Pinecone, the PM chooses ChromaDB, the Dev chooses Qdrant.

Solution: This reveals different priorities, not a methodology problem. Aggregate the weights (average) and only discuss the criteria where the divergence is greater than 2 points. The disagreement IS valuable information.

Problem 5: "A provider wins because of the weights, not because it's better"

Symptom: If you change the weights slightly, another provider wins.

Solution: Do a sensitivity analysis. If the result changes with ±1 variations in a weight, the decision is fragile. In that case, prioritize the provider with the lowest operational risk as a tiebreaker.


🏋️ Exercises

Exercise 1: Fixed point distribution

Distribute 100 points among these 6 criteria for an internal RAG project (mid-size company, 3 developers, no SRE, 500K expected vectors):

  • Latency
  • Cost
  • Operational complexity
  • SDK quality
  • Community
  • Compliance
Solution
# For internal RAG, mid-size company, no SRE, 500K vectors:
my_distribution = {
    "latency": 15,       # Internal: moderate tolerance
    "cost": 20,          # Limited budget
    "ops_complexity": 30, # No SRE: OPS dominates the decision
    "sdk_quality": 15,   # 3 devs = SDK matters
    "community": 10,     # Important but not critical
    "compliance": 10,    # Mid-size company: basic requirements
}

assert sum(my_distribution.values()) == 100, "Must add up to 100"

print("Distribution:")
for criterion, points in sorted(my_distribution.items(), key=lambda x: -x[1]):
    bar = "█" * (points // 2)
    print(f"  {criterion:<20} {points:>3} pts {bar}")

# Ops dominates because without an SRE, every hour of maintenance
# is an hour a developer isn't building features

Exercise 2: Pairwise comparison

Do the 15 pairwise comparisons for the 6 criteria from the previous exercise. Does the result match your fixed distribution?

Solution
from itertools import combinations

criteria = ["latency", "cost", "ops", "sdk", "community", "compliance"]

# For each pair: which is more important for internal RAG without an SRE?
my_preferences = {
    ("latency", "cost"): "cost",           # Internal: budget > speed
    ("latency", "ops"): "ops",             # No SRE: ops always wins
    ("latency", "sdk"): "latency",         # Latency > SDK
    ("latency", "community"): "latency",   # Latency > community
    ("latency", "compliance"): "latency",  # Latency > basic compliance
    ("cost", "ops"): "ops",                # No SRE: ops wins vs cost
    ("cost", "sdk"): "cost",               # Budget > SDK
    ("cost", "community"): "cost",         # Budget > community
    ("cost", "compliance"): "cost",        # Budget > compliance
    ("ops", "sdk"): "ops",                 # Ops > SDK
    ("ops", "community"): "ops",           # Ops > community
    ("ops", "compliance"): "ops",          # Ops > compliance
    ("sdk", "community"): "sdk",           # SDK > community
    ("sdk", "compliance"): "sdk",          # SDK > compliance
    ("community", "compliance"): "community",  # Community > compliance
}

weights = pairwise_comparison(criteria, my_preferences)
print("Pairwise weights:")
for c, w in sorted(weights.items(), key=lambda x: -x[1]):
    print(f"  {c}: {w:.1%}")

# Comparison: fixed distribution → ops 30%, cost 20%, latency 15%
# Pairwise will probably give: ops > cost > latency (consistent)

Exercise 3: Scoring with rubrics

Score ChromaDB and Pinecone using the rubrics from the previous section for these criteria: latency_p95, cost_monthly, ops_hours. Justify each score with evidence.

Solution
scoring_with_evidence = {
    "ChromaDB": {
        "latency_p95": {
            "score": 0.5,
            "raw_value": "300-500ms (self-hosted, depends on hardware)",
            "evidence": "No official p95 benchmarks. Estimate from community reports.",
            "rubric_level": "Partial compliance"
        },
        "cost_monthly": {
            "score": 1.0,
            "raw_value": "$0-60/month (self-hosted on a small VM)",
            "evidence": "Open source + VM cost. Effective free tier up to 100K.",
            "rubric_level": "Fully meets"
        },
        "ops_hours": {
            "score": 0.25,
            "raw_value": "10-15h/month (no mature managed option)",
            "evidence": "Requires manual monitoring, manual backups, updates.",
            "rubric_level": "Weak compliance"
        }
    },
    "Pinecone": {
        "latency_p95": {
            "score": 0.75,
            "raw_value": "~120ms (per public benchmarks)",
            "evidence": "Official benchmarks and ANN-benchmarks community.",
            "rubric_level": "Meets well"
        },
        "cost_monthly": {
            "score": 0.5,
            "raw_value": "$70-350/month (depends on tier and vectors)",
            "evidence": "Official pricing page. Free tier up to 100K 1536d vectors.",
            "rubric_level": "Partial compliance (for a budget < $200)"
        },
        "ops_hours": {
            "score": 1.0,
            "raw_value": "1-2h/month (fully managed)",
            "evidence": "No own infra required. Only integration monitoring.",
            "rubric_level": "Fully meets"
        }
    }
}

for provider, criteria in scoring_with_evidence.items():
    print(f"\n=== {provider} ===")
    for criterion, data in criteria.items():
        print(f"  {criterion}: {data['score']} ({data['rubric_level']})")
        print(f"    Raw: {data['raw_value']}")
        print(f"    Evidence: {data['evidence']}")

Exercise 4: Sensitivity analysis

Using the ScoringEngine, check whether your ranking changes when you vary the weight of "cost_monthly" from 1 to 5. Is the decision robust or fragile?

Solution
engine_test = ScoringEngine([
    CriterionConfig("latency_p95", weight=4.0, min_acceptable=500,
                    ideal_value=100, lower_is_better=True),
    CriterionConfig("cost_monthly", weight=3.0, min_acceptable=500,
                    ideal_value=100, lower_is_better=True),
    CriterionConfig("ops_hours", weight=5.0, min_acceptable=15,
                    ideal_value=2, lower_is_better=True),
])

engine_test.add_provider("Pinecone", {
    "latency_p95": 120, "cost_monthly": 350, "ops_hours": 2
})
engine_test.add_provider("Qdrant Cloud", {
    "latency_p95": 150, "cost_monthly": 200, "ops_hours": 3
})
engine_test.add_provider("ChromaDB Self", {
    "latency_p95": 350, "cost_monthly": 60, "ops_hours": 12
})

results = engine_test.sensitivity_analysis("cost_monthly", [1.0, 2.0, 3.0, 4.0, 5.0])

print("Sensitivity to the weight of 'cost_monthly':")
for weight, ranking in results.items():
    leader = ranking[0]
    print(f"  Weight {weight:.0f}: Winner = {leader[0]} ({leader[1]}%)")

# If the winner changes from Pinecone to ChromaDB when cost rises to 5,
# your decision is sensitive to the cost criterion. Document this.

Exercise 5: Bias detection

Review this hypothetical scoring and detect which biases might be present:

Criterion"Our current provider""New provider"
Latency0.90.7
Cost0.80.8
Ops0.90.6
SDK0.950.5
Community0.850.4
Solution
analysis = {
    "familiarity_bias": {
        "detected": True,
        "evidence": "The current provider has scores > 0.8 on EVERYTHING. "
                    "It's statistically unlikely it's superior in every dimension.",
        "action": "Include an evaluator who has NOT used the current provider"
    },
    "halo_effect": {
        "detected": True,
        "evidence": "SDK 0.95 is suspiciously high. "
                    "Is the SDK really 'almost perfect'? Or have you already learned its quirks?",
        "action": "Apply an objective rubric to the SDK: type hints, async, docs, testing mode"
    },
    "anchoring_bias": {
        "detected": True,
        "evidence": "If you evaluated the current one first, the new one's scores are compared "
                    "against the current one instead of against the rubric",
        "action": "Evaluate both against the rubric, not one against the other"
    },
    "sunk_cost": {
        "detected": "Possible",
        "evidence": "Community 0.85 vs 0.4 is a huge gap. "
                    "Does the current one really have a better community, or do YOU just know its community more?",
        "action": "Measure community with objective metrics: stars, SO answers, integration count"
    }
}

for bias, info in analysis.items():
    status = "🔴" if info["detected"] == True else "🟡"
    print(f"{status} {bias}: {info['evidence']}")
    print(f"   → {info['action']}")
    print()

🔗 Connection with the project: Decision Questionnaire

In your Decision Questionnaire, the ScoringEngine from this capsule is the central calculation engine:

  1. The questionnaire collects inputs → they become CriterionConfig (weights and thresholds)
  2. The provider data → is fed in as raw_scores
  3. The engine calculates → normalization + weighting
  4. The final report includes ranking, gap analysis, and a sensitivity check

Reuse the ScoringEngine, normalize_score, and sensitivity_analysis code directly in your project.


Summary

  • Weights determine the result more than the scores. Invest time in the weights.
  • Three techniques for assigning weights: fixed distribution (forces trade-offs), pairwise comparison (intuitive), stack ranking (fast).
  • Without a rubric, the score is opinion. Define what 0.5 vs 0.75 means for each criterion.
  • Normalize before weighting. Latency in ms and cost in USD can't be added directly.
  • Do a sensitivity analysis. If changing a weight by ±1 changes the winner, your decision is fragile.
  • Detect biases systematically. Anchoring, familiarity, recency, and halo effect are the most common.
  • Ties aren't resolved with more decimals. If two options are within ±3%, decide by operational simplicity.

Additional resources

  1. Weighted Sum Model (Wikipedia) — Theoretical foundation of the weighted scoring matrix
  2. Pairwise Comparison Method — Pairwise comparison method for prioritization
  3. MoSCoW Method — A complementary prioritization framework
  4. AHP (Analytic Hierarchy Process) — Advanced multi-criteria decision method
  5. Cognitive Biases in Decision Making — List of relevant cognitive biases
  6. ANN Benchmarks — Objective data for performance scoring
  7. MCDA (Multi-Criteria Decision Analysis) — Academic framework for complex decisions
  8. Decision Quality Framework (SDG) — Professional decision quality framework

Estimated time: 25-35 minutes Next: 04-practical-decision-matrix.md