Advocacy vs Inquiry – How Teams Make (or Break) Security Decisions#
Most security decisions are made by groups – architecture review boards, incident response teams, risk committees, compliance councils. The quality of these decisions depends less on the expertise in the room than on how the group processes information.
Two modes dominate group decision-making (Garvin & Roberto, 2001):
Advocacy: Each participant argues for their position. The goal is to persuade. Evidence is brought in service of a pre-formed conclusion. The loudest, most senior, or most persistent voice wins.
Inquiry: The group jointly explores the problem. The goal is to understand. Evidence is surfaced and weighed collectively. Disagreement is treated as information, not opposition.
Advocacy feels productive – people are engaged, passionate, articulate. But it degrades decision quality when power or information is unevenly spread. Just trying to convince the other of my point of view, there is no inquiry. There is no genuine interest in solving the problem. It just turns into a battle of positions, ego, and a plea to each other to see my point of view.
This notebook quantifies the damage and models the fix.
Reference: Garvin, D. A. & Roberto, M. A. (2001). What You Don’t Know About Making Decisions. Harvard Business Review, 79(8), 108–116.
Setup#
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from decision_security.synth import make_rng, sample
rng = make_rng(42)
plt.rcParams.update({
"font.family": "serif",
"font.size": 10,
"axes.labelsize": 11,
"axes.titlesize": 12,
"xtick.labelsize": 9,
"ytick.labelsize": 9,
"legend.fontsize": 9,
"figure.dpi": 150,
"axes.spines.top": False,
"axes.spines.right": False,
})
PRIMARY = "#1A1A1A"
ACCENT = "#E74C3C"
DARK_BG = "#34495E"
LIGHT_GRAY = "#95A5A6"
1. The Architecture Review Board Problem#
A security architecture review board is deciding whether to adopt zero-trust network access (ZTNA) or keep the existing VPN + firewall perimeter model. The true risk reduction from ZTNA (relative to VPN) is, say, 35% – but nobody knows this exactly.
Five stakeholders sit at the table. Each has a different position strength (how loudly they advocate) and a different information quality (how much genuine signal their estimate carries).
Stakeholder |
Position Strength |
Info Quality |
Notes |
|---|---|---|---|
CISO |
0.90 |
0.50 |
Strong opinions, moderate data |
Network Architect |
0.40 |
0.90 |
Quiet, but has done the analysis |
VP Engineering |
0.80 |
0.30 |
Loud, mostly concerned about migration cost |
SOC Lead |
0.50 |
0.70 |
Has real incident data |
Compliance Officer |
0.60 |
0.40 |
Regulatory lens, limited technical depth |
In advocacy mode, the group decision is a weighted average where weights are position strength – the loudest voice dominates.
In inquiry mode, weights are information quality – the most informed voice contributes most.
(In the simulation, noise is capped at σ = 0.20, so CISO, VP Engineering and Compliance end up equally noisy.)
# --- Architecture Review Board: Advocacy vs Inquiry ---
TRUE_RISK_REDUCTION = 0.35 # ZTNA vs VPN
stakeholders = {
"CISO": {"position_strength": 0.90, "info_quality": 0.50},
"Network Architect": {"position_strength": 0.40, "info_quality": 0.90},
"VP Engineering": {"position_strength": 0.80, "info_quality": 0.30},
"SOC Lead": {"position_strength": 0.50, "info_quality": 0.70},
"Compliance Officer": {"position_strength": 0.60, "info_quality": 0.40},
}
names = list(stakeholders.keys())
pos_strength = np.array([stakeholders[n]["position_strength"] for n in names])
info_quality = np.array([stakeholders[n]["info_quality"] for n in names])
# sigma falls with info_quality, capped at 0.20 so the noisiest voices don't
# routinely clip against the [0, 1] bounds (which would distort the average).
sigmas = np.minimum(0.12 / info_quality, 0.20)
# Weights are fixed by role; only the estimates vary from draw to draw.
adv_weights = pos_strength / pos_strength.sum()
inq_weights = info_quality / info_quality.sum()
def draw_estimates(r):
"""One board's estimates, each drawn around the truth."""
return np.clip(np.array([
sample("normal", 1, rng=r, loc=TRUE_RISK_REDUCTION, scale=s)[0]
for s in sigmas
]), 0.0, 1.0)
n_trials = 1000
adv_errs = np.zeros(n_trials)
inq_errs = np.zeros(n_trials)
for t in range(n_trials):
est_t = draw_estimates(make_rng(1000 + t))
adv_errs[t] = abs(np.dot(adv_weights, est_t) - TRUE_RISK_REDUCTION)
inq_errs[t] = abs(np.dot(inq_weights, est_t) - TRUE_RISK_REDUCTION)
frac_inquiry_better = float((inq_errs < adv_errs).mean())
# Illustrative draw for the table and plot. Seed 7: no estimate clips to 0/1,
# and inquiry lands closer to the truth than advocacy, as in most boards.
estimates = draw_estimates(make_rng(7))
advocacy_decision = np.dot(adv_weights, estimates)
inquiry_decision = np.dot(inq_weights, estimates)
print(f"True risk reduction from ZTNA: {TRUE_RISK_REDUCTION:.0%}")
print("One illustrative board:")
print(f"{'':─<60}")
print(f"{'Stakeholder':<22} {'Estimate':>8} {'Adv Wt':>8} {'Inq Wt':>8}")
print(f"{'':─<22} {'':─>8} {'':─>8} {'':─>8}")
for i, name in enumerate(names):
print(f"{name:<22} {estimates[i]:>8.1%} {adv_weights[i]:>8.1%} {inq_weights[i]:>8.1%}")
print(f"{'':─<60}")
print(f"{'Advocacy decision':<22} {advocacy_decision:>8.1%}")
print(f"{'Inquiry decision':<22} {inquiry_decision:>8.1%}")
print(f"{'True value':<22} {TRUE_RISK_REDUCTION:>8.1%}")
print(f"\nAdvocacy error (this draw): {abs(advocacy_decision - TRUE_RISK_REDUCTION) * 100:.1f} pp")
print(f"Inquiry error (this draw): {abs(inquiry_decision - TRUE_RISK_REDUCTION) * 100:.1f} pp")
print(f"\nAcross {n_trials} simulated boards:")
print(f" Mean advocacy error: {adv_errs.mean() * 100:.1f} pp")
print(f" Mean inquiry error: {inq_errs.mean() * 100:.1f} pp")
print(f" Inquiry beats advocacy in {frac_inquiry_better:.0%} of boards")
True risk reduction from ZTNA: 35%
One illustrative board:
────────────────────────────────────────────────────────────
Stakeholder Estimate Adv Wt Inq Wt
────────────────────── ──────── ──────── ────────
CISO 35.0% 28.1% 17.9%
Network Architect 39.0% 12.5% 32.1%
VP Engineering 29.5% 25.0% 10.7%
SOC Lead 19.7% 15.6% 25.0%
Compliance Officer 25.9% 18.7% 14.3%
────────────────────────────────────────────────────────────
Advocacy decision 30.0%
Inquiry decision 30.6%
True value 35.0%
Advocacy error (this draw): 5.0 pp
Inquiry error (this draw): 4.4 pp
Across 1000 simulated boards:
Mean advocacy error: 6.7 pp
Mean inquiry error: 6.0 pp
Inquiry beats advocacy in 56% of boards
# --- Bar chart: stakeholder contributions in each mode (one illustrative draw) ---
fig, axes = plt.subplots(1, 2, figsize=(10, 4), sharey=True)
x = np.arange(len(names))
short_names = ["CISO", "Net Arch", "VP Eng", "SOC Lead", "Compl"]
axes[0].bar(x, adv_weights, color=ACCENT, alpha=0.85, edgecolor="white")
axes[0].set_xticks(x)
axes[0].set_xticklabels(short_names, rotation=30, ha="right")
axes[0].set_ylabel("Decision weight")
axes[0].set_title("Advocacy Mode\n(weight = position strength)")
axes[0].set_ylim(0, 0.40)
axes[1].bar(x, inq_weights, color=DARK_BG, alpha=0.85, edgecolor="white")
axes[1].set_xticks(x)
axes[1].set_xticklabels(short_names, rotation=30, ha="right")
axes[1].set_title("Inquiry Mode\n(weight = information quality)")
# Annotate each stakeholder's estimate from this draw
for ax, wts in [(axes[0], adv_weights), (axes[1], inq_weights)]:
for i in range(len(names)):
ax.text(i, wts[i] + 0.01, f"{estimates[i]:.0%}",
ha="center", va="bottom", fontsize=8, color=PRIMARY)
fig.suptitle("Who drives the decision? (one illustrative draw)",
fontsize=13, fontweight="bold", y=1.02)
fig.tight_layout()
plt.show()
print(f"Network Architect (best data): {adv_weights[1]:.0%} weight in advocacy mode "
f"vs {inq_weights[1]:.0%} in inquiry mode.")
print(f"VP Engineering (a loud voice with the least data): {adv_weights[2]:.0%} weight "
f"in advocacy mode vs {inq_weights[2]:.0%} in inquiry mode.")
Network Architect (best data): 12% weight in advocacy mode vs 32% in inquiry mode.
VP Engineering (a loud voice with the least data): 25% weight in advocacy mode vs 11% in inquiry mode.
2. Information Pooling Failure#
The “hidden profile” problem is one of the most replicated findings in group decision research (Stasser & Titus, 1985).
A team of 5 analysts is evaluating a threat. Collectively they have 15 pieces of evidence. But the evidence is distributed unevenly:
5 pieces are shared – everyone has seen them
10 pieces are unique – each analyst has 2 pieces nobody else has seen
The shared evidence points toward Threat A. But the unique evidence, taken together, overwhelmingly points toward Threat B. The correct answer is B – but only if you pool all 15 pieces.
In advocacy mode, people discuss what they already agree on. Shared evidence dominates because it is easy to advocate for. Unique evidence often never gets mentioned.
In inquiry mode, the group deliberately surfaces what each person knows that others do not.
# --- Hidden Profile: Shared vs Unique Evidence ---
# Constructed, not sampled (Stasser & Titus, 1985 design): shared evidence favors
# Threat A, while the pooled unique evidence outweighs it toward Threat B.
# Values are log-odds shifts: positive toward A, negative toward B.
shared_evidence = np.array([0.6, 0.5, 0.4, 0.7, 0.3])
# 10 unique pieces, 2 per analyst, all favoring Threat B
unique_evidence = {
"Analyst 1": np.array([-0.8, -0.5]),
"Analyst 2": np.array([-0.6, -0.9]),
"Analyst 3": np.array([-0.4, -0.7]),
"Analyst 4": np.array([-0.5, -0.3]),
"Analyst 5": np.array([-0.6, -0.8]),
}
prior_log_odds = 0.0 # 50-50 prior
# Advocacy: only shared evidence gets discussed
advocacy_log_odds = prior_log_odds + shared_evidence.sum()
advocacy_prob_A = 1.0 / (1.0 + np.exp(-advocacy_log_odds))
# Inquiry: all evidence gets pooled
all_unique = np.concatenate(list(unique_evidence.values()))
inquiry_log_odds = prior_log_odds + shared_evidence.sum() + all_unique.sum()
inquiry_prob_A = 1.0 / (1.0 + np.exp(-inquiry_log_odds))
def pick(p):
return "A" if p > 0.5 else "B"
print("Evidence summary")
print(f" Shared evidence (5 pieces): total log-odds shift = {shared_evidence.sum():+.1f} (favors A)")
print(f" Unique evidence (10 pieces): total log-odds shift = {all_unique.sum():+.1f} (favors B)")
print(f" Combined: total log-odds shift = {shared_evidence.sum() + all_unique.sum():+.1f}")
print()
print(f"Advocacy mode (shared only): P(Threat A) = {advocacy_prob_A:.1%} --> Team picks {pick(advocacy_prob_A)}")
print(f"Inquiry mode (all pooled): P(Threat A) = {inquiry_prob_A:.1%} --> Team picks {pick(inquiry_prob_A)}")
print("\nThe correct answer is Threat B, by construction.")
Evidence summary
Shared evidence (5 pieces): total log-odds shift = +2.5 (favors A)
Unique evidence (10 pieces): total log-odds shift = -6.1 (favors B)
Combined: total log-odds shift = -3.6
Advocacy mode (shared only): P(Threat A) = 92.4% --> Team picks A
Inquiry mode (all pooled): P(Threat A) = 2.7% --> Team picks B
The correct answer is Threat B, by construction.
# --- Visualize shared vs unique evidence ---
fig, axes = plt.subplots(1, 2, figsize=(10, 4.5))
# Left: evidence structure diagram
ax = axes[0]
analyst_names = list(unique_evidence.keys())
y_positions = np.arange(len(analyst_names))
n_shared = len(shared_evidence)
n_unique_each = len(next(iter(unique_evidence.values())))
group_gap = 0.5 # between analysts' unique blocks
unique_start = n_shared + 0.5
block_span = n_unique_each + group_gap
for i, y in enumerate(y_positions):
for j in range(n_shared):
ax.scatter(j, y, s=120, c=ACCENT, marker="s", zorder=3,
edgecolors="white", linewidths=0.5)
for j in range(n_unique_each):
ax.scatter(unique_start + j + i * block_span, y, s=120, c=DARK_BG, marker="o",
zorder=3, edgecolors="white", linewidths=0.5)
max_x = unique_start + (n_unique_each - 1) + (len(analyst_names) - 1) * block_span
ax.set_yticks(y_positions)
ax.set_yticklabels(analyst_names)
ax.set_xlim(-0.5, max_x + 1.5)
ax.set_xlabel("Evidence pieces")
ax.set_title("Evidence distribution across team")
ax.scatter([], [], s=100, c=ACCENT, marker="s", label="Shared (favors A)")
ax.scatter([], [], s=100, c=DARK_BG, marker="o", label="Unique (favors B)")
ax.legend(loc="lower right", frameon=True, fancybox=False, edgecolor=LIGHT_GRAY)
# Right: posterior probability comparison
ax2 = axes[1]
modes = ["Advocacy\n(shared only)", "Inquiry\n(all evidence)"]
probs_A = [advocacy_prob_A, inquiry_prob_A]
colors = [ACCENT, DARK_BG]
bars = ax2.bar(modes, probs_A, color=colors, alpha=0.85, edgecolor="white", width=0.5)
ax2.axhline(0.5, color=LIGHT_GRAY, linestyle="--", linewidth=1, label="50% threshold")
ax2.set_ylabel("P(Threat A)")
ax2.set_ylim(0, 1.0)
ax2.set_title("Posterior probability of Threat A")
for bar, p in zip(bars, probs_A):
ax2.text(bar.get_x() + bar.get_width() / 2, p + 0.03,
f"{p:.0%}", ha="center", va="bottom", fontweight="bold")
ax2.annotate("Correct answer\nis Threat B", xy=(1, inquiry_prob_A),
xytext=(1.35, 0.55), fontsize=8, color=DARK_BG,
arrowprops=dict(arrowstyle="->", color=DARK_BG, lw=1.2))
fig.tight_layout()
plt.show()
Check it in your own environment.
Before anyone states a conclusion, have each person list what they observed that others may not have seen.
Record those unique observations and check, at the end, whether each one was addressed.
Note who spoke first and whether the final answer matched their opening view.
3. The HiPPO Effect#
HiPPO – the Highest Paid Person’s Opinion. In many security organizations, the CISO or CTO has implicit veto power over group decisions. Not because they are the most informed, but because they are the most senior.
We model a recurring meeting where 4 analysts and 1 executive each estimate breach probability. The executive is no more accurate than the analysts (σ = 0.08, mid-pack) and equally unbiased.
We run 500 simulated meetings and compare mean squared error under three weighting schemes:
Advocacy: executive gets 5x weight
Inquiry: equal weight for everyone (nobody’s track record is known)
Optimal (among weighted averages): weight by each person’s true noise level (inverse variance)
# --- HiPPO Effect: 500 simulated meetings ---
N_MEETINGS = 500
N_ANALYSTS = 4
N_PARTICIPANTS = N_ANALYSTS + 1 # 4 analysts + 1 executive
true_probs = rng.beta(2, 8, size=N_MEETINGS) # mean 0.2
analyst_sigmas = np.array([0.08, 0.06, 0.10, 0.07])
exec_sigma = 0.08 # mid-pack: no better than the analysts
all_sigmas = np.append(analyst_sigmas, exec_sigma)
adv_w = np.array([1, 1, 1, 1, 5], dtype=float) # executive gets 5x
adv_w /= adv_w.sum()
inq_w = np.ones(N_PARTICIPANTS) / N_PARTICIPANTS
# Inverse-variance weights from each person's true sigma (an oracle, not an estimate)
opt_w = 1.0 / all_sigmas**2
opt_w /= opt_w.sum()
errors_advocacy = np.zeros(N_MEETINGS)
errors_inquiry = np.zeros(N_MEETINGS)
errors_optimal = np.zeros(N_MEETINGS)
for m in range(N_MEETINGS):
truth = true_probs[m]
noise = rng.normal(0, all_sigmas)
estimates = np.clip(truth + noise, 0, 1)
dec_adv = np.dot(adv_w, estimates)
dec_inq = np.dot(inq_w, estimates)
dec_opt = np.dot(opt_w, estimates)
errors_advocacy[m] = (dec_adv - truth) ** 2
errors_inquiry[m] = (dec_inq - truth) ** 2
errors_optimal[m] = (dec_opt - truth) ** 2
print(f"Mean Squared Error across {N_MEETINGS} meetings:")
print(f" Advocacy (HiPPO 5x): {errors_advocacy.mean():.5f}")
print(f" Inquiry (equal wt): {errors_inquiry.mean():.5f}")
print(f" Optimal (inv-var): {errors_optimal.mean():.5f}")
advocacy_ratio = errors_advocacy.mean() / errors_inquiry.mean()
print(f"\nThe executive is no more accurate than the analysts (sigma={exec_sigma}, mid-pack).")
if advocacy_ratio >= 1:
print(f"Advocacy MSE is {advocacy_ratio:.1f}x worse than inquiry: pure lost averaging,")
print("since weighting one voice throws away the variance reduction of five estimates.")
else:
print(f"Advocacy MSE is {1 / advocacy_ratio:.1f}x better than inquiry -- sampling noise,")
print("since weighting that one voice has no accuracy edge to exploit.")
Mean Squared Error across 500 meetings:
Advocacy (HiPPO 5x): 0.00202
Inquiry (equal wt): 0.00112
Optimal (inv-var): 0.00105
The executive is no more accurate than the analysts (sigma=0.08, mid-pack).
Advocacy MSE is 1.8x worse than inquiry: pure lost averaging,
since weighting one voice throws away the variance reduction of five estimates.
# --- Box plot of squared errors across 500 meetings ---
fig, ax = plt.subplots(figsize=(7, 4.5))
data = [errors_advocacy, errors_inquiry, errors_optimal]
tick_labels = ["Advocacy\n(HiPPO 5x)", "Inquiry\n(equal weight)", "Optimal\n(inv-variance)"]
colors_bp = [ACCENT, DARK_BG, LIGHT_GRAY]
bp = ax.boxplot(data, tick_labels=tick_labels, patch_artist=True, widths=0.5,
medianprops=dict(color=PRIMARY, linewidth=1.5),
whiskerprops=dict(color=LIGHT_GRAY),
capprops=dict(color=LIGHT_GRAY),
flierprops=dict(marker=".", markersize=3, markerfacecolor=LIGHT_GRAY,
markeredgecolor=LIGHT_GRAY))
for patch, color in zip(bp["boxes"], colors_bp):
patch.set_facecolor(color)
patch.set_alpha(0.7)
ax.set_ylabel("Squared error per meeting")
ax.set_title("Decision accuracy: 500 simulated security meetings")
medians = [np.median(d) for d in data]
for i, med in enumerate(medians):
ax.text(i + 1, med + 0.001, f"median = {med:.4f}",
ha="center", va="bottom", fontsize=8, color=PRIMARY)
fig.tight_layout()
plt.show()
4. Constructive Disagreement Protocol#
If advocacy is the disease, structured inquiry is the treatment. The protocol is simple and free:
Before the meeting: Each person writes their estimate independently, without any prior discussion, Slack threads, or pre-alignment. This prevents anchoring on each other’s numbers.
Start of meeting: All estimates are revealed simultaneously (e.g., on sticky notes, or via anonymous poll). No serial disclosure.
Discussion: Focus on the largest disagreements first. If everyone agrees, there is nothing to discuss. If two people are far apart, one of them knows something the other does not.
After discussion: Each person updates their estimate independently, without group vote or consensus pressure.
We compare independent estimation (steps 1–2) against serial discussion, where each person states their estimate one at a time (and each subsequent person anchors on the running mean of earlier speakers).
# --- Structured Protocol vs Serial Discussion ---
N_ROUNDS = 5000
N_PEOPLE = 5
ANCHOR_STRENGTH = 0.50 # weight each person puts on the running mean of earlier speakers
true_values = sample("normal", N_ROUNDS, rng=rng, loc=0.30, scale=0.10)
true_values = np.clip(true_values, 0.05, 0.95)
individual_sigma = 0.12
structured_decisions = np.zeros(N_ROUNDS)
serial_decisions = np.zeros(N_ROUNDS)
# Per-person deviations from truth, to measure how correlated individual errors are.
structured_dev = np.zeros((N_ROUNDS, N_PEOPLE))
serial_dev = np.zeros((N_ROUNDS, N_PEOPLE))
for r in range(N_ROUNDS):
truth = true_values[r]
# Structured (steps 1-2 only): independent estimates, then average
independent_estimates = np.clip(
truth + rng.normal(0, individual_sigma, size=N_PEOPLE), 0, 1
)
structured_decisions[r] = independent_estimates.mean()
structured_dev[r] = independent_estimates - truth
# Serial: the first speaker sets the anchor; each later speaker blends their own
# signal with the running mean of earlier speakers (cumulative conformity pressure).
serial_estimates = np.zeros(N_PEOPLE)
for p in range(N_PEOPLE):
own_signal = truth + rng.normal(0, individual_sigma)
if p == 0:
serial_estimates[p] = np.clip(own_signal, 0, 1)
else:
group_anchor = serial_estimates[:p].mean()
anchored = (1 - ANCHOR_STRENGTH) * own_signal + ANCHOR_STRENGTH * group_anchor
serial_estimates[p] = np.clip(anchored, 0, 1)
serial_decisions[r] = serial_estimates.mean()
serial_dev[r] = serial_estimates - truth
structured_errors = (structured_decisions - true_values) ** 2
serial_errors = (serial_decisions - true_values) ** 2
mse_ratio = serial_errors.mean() / structured_errors.mean()
print(f"Results across {N_ROUNDS} simulated rounds:")
print(f" Structured protocol MSE: {structured_errors.mean():.5f}")
print(f" Serial discussion MSE: {serial_errors.mean():.5f}")
print(f" Serial / structured MSE: {mse_ratio:.2f}x")
print(f"\n Structured std of decision errors: {(structured_decisions - true_values).std():.4f}")
print(f" Serial std of decision errors: {(serial_decisions - true_values).std():.4f}")
def mean_pairwise_corr(dev):
"""Average correlation between distinct participants' errors."""
c = np.corrcoef(dev, rowvar=False)
return c[np.triu_indices_from(c, k=1)].mean()
structured_corr = mean_pairwise_corr(structured_dev)
serial_corr = mean_pairwise_corr(serial_dev)
print(f"\n Mean pairwise correlation of individual errors:")
print(f" Structured protocol: {structured_corr:+.3f}")
print(f" Serial discussion: {serial_corr:+.3f}")
if serial_corr > structured_corr:
print("\nSerial anchoring correlates the estimates. Averaging correlated estimates cancels")
print("less noise, so the first speaker's error propagates instead of washing out.")
else:
print("\nSerial anchoring did not raise the error correlation in this run.")
Results across 5000 simulated rounds:
Structured protocol MSE: 0.00283
Serial discussion MSE: 0.00426
Serial / structured MSE: 1.51x
Structured std of decision errors: 0.0531
Serial std of decision errors: 0.0652
Mean pairwise correlation of individual errors:
Structured protocol: +0.006
Serial discussion: +0.488
Serial anchoring correlates the estimates. Averaging correlated estimates cancels
less noise, so the first speaker's error propagates instead of washing out.
# --- Errors of the group decision under both protocols ---
struct_err = structured_decisions - true_values
serial_err = serial_decisions - true_values
struct_rmse = np.sqrt(np.mean(struct_err ** 2))
serial_rmse = np.sqrt(np.mean(serial_err ** 2))
fig, ax = plt.subplots(figsize=(8, 4.5))
lim = np.abs(np.concatenate([struct_err, serial_err])).max()
bins = np.linspace(-lim, lim, 31)
# Filled vs outline so the two distributions stay readable where they overlap.
ax.hist(struct_err, bins=bins, color=DARK_BG, alpha=0.35, edgecolor="none",
label=f"Structured protocol (RMSE {struct_rmse:.3f})")
ax.hist(serial_err, bins=bins, histtype="step", color=ACCENT, linewidth=2,
label=f"Serial discussion (RMSE {serial_rmse:.3f})")
ax.axvline(0, color=PRIMARY, lw=1)
ax.set_xlabel("Group decision minus true value")
ax.set_ylabel("Rounds")
ax.set_title(f"Group decision errors ({N_ROUNDS} rounds)")
ax.legend(frameon=False)
fig.tight_layout()
plt.show()
if struct_rmse < serial_rmse:
print(f"The structured protocol makes smaller errors (RMSE {struct_rmse:.3f} vs {serial_rmse:.3f}).")
else:
print(f"In this run serial discussion makes smaller errors (RMSE {serial_rmse:.3f} vs {struct_rmse:.3f});")
print("independent estimation bought no accuracy here.")
The structured protocol makes smaller errors (RMSE 0.053 vs 0.065).
5. When Advocacy Is Appropriate#
Advocacy is not always bad. If one team member has ground truth, if they found the root cause in the logs, if they have the forensic evidence, then advocating for the correct conclusion is efficient. The problem is when advocacy substitutes for inquiry: when nobody has ground truth and everyone is arguing their prior.
Below, as in section 3, inquiry is modelled as equal weighting, so advocacy wins with high information only because the loudest voice is the expert.
This gives us a 2x2 matrix:
Advocacy |
Inquiry |
|
|---|---|---|
High Information |
Effective (expert persuades) |
Less accurate (expert’s signal diluted) |
Low Information |
Dangerous (loudest wins) |
Productive (surfaces assumptions) |
# --- Simulate the 2x2: MSE under each combination ---
N_SIM = 1000
N_TEAM = 5
# High info: one expert (sigma=0.02), four at sigma=0.15. Low info: all at 0.20.
# Inquiry is modelled as equal weighting (nobody's track record is known), so advocacy
# can win under high info only because the loudest voice (person 0) is the expert.
mode_weights = {
"Advocacy": np.array([5, 1, 1, 1, 1], dtype=float), # person 0 is loudest
"Inquiry": np.ones(N_TEAM, dtype=float),
}
mode_weights = {m: w / w.sum() for m, w in mode_weights.items()}
results = {}
for info_level, sigmas_vec in [
("High Info", np.array([0.02, 0.15, 0.15, 0.15, 0.15])),
("Low Info", np.array([0.20, 0.20, 0.20, 0.20, 0.20])),
]:
# Score both modes on the same draws (paired), so any MSE gap is the weighting.
truths = rng.beta(3, 7, size=N_SIM)
se = {mode: np.zeros(N_SIM) for mode in mode_weights}
for i in range(N_SIM):
ests = np.clip(truths[i] + rng.normal(0, sigmas_vec), 0, 1)
for mode, w in mode_weights.items():
dec = np.dot(w, ests)
se[mode][i] = (dec - truths[i]) ** 2
for mode in mode_weights:
results[(info_level, mode)] = se[mode].mean()
print(f"{'':─<50}")
print(f"{'MSE by (Information Level, Decision Mode)':^50}")
print(f"{'':─<50}")
print(f"{'':20} {'Advocacy':>14} {'Inquiry':>14}")
print(f"{'High Info':20} {results[('High Info', 'Advocacy')]:>14.5f} {results[('High Info', 'Inquiry')]:>14.5f}")
print(f"{'Low Info':20} {results[('Low Info', 'Advocacy')]:>14.5f} {results[('Low Info', 'Inquiry')]:>14.5f}")
print(f"{'':─<50}")
advocacy_wins = {}
for info_level in ["High Info", "Low Info"]:
advocacy_wins[info_level] = results[(info_level, "Advocacy")] < results[(info_level, "Inquiry")]
print(f"{info_level}: {'advocacy' if advocacy_wins[info_level] else 'inquiry'} wins.")
if advocacy_wins["High Info"] and not advocacy_wins["Low Info"]:
print("\nThe ranking flips with the information level. Concentrating weight on one")
print("voice is safe only when that voice provably holds ground truth;")
print("when nobody does, inquiry's averaging wins.")
else:
print("\nNo advocacy-to-inquiry flip in this run.")
──────────────────────────────────────────────────
MSE by (Information Level, Decision Mode)
──────────────────────────────────────────────────
Advocacy Inquiry
High Info 0.00118 0.00343
Low Info 0.01128 0.00634
──────────────────────────────────────────────────
High Info: advocacy wins.
Low Info: inquiry wins.
The ranking flips with the information level. Concentrating weight on one
voice is safe only when that voice provably holds ground truth;
when nobody does, inquiry's averaging wins.
# --- 2x2 Matrix visualization ---
fig, ax = plt.subplots(figsize=(7, 5))
# Cell definitions: (row, col, label, mse, color, text_color)
cells = [
(0, 0, "Effective\n(expert persuades)",
results[("High Info", "Advocacy")], DARK_BG, "white"),
(0, 1, "Less accurate\n(expert's signal diluted)",
results[("High Info", "Inquiry")], "#D6DBDF", PRIMARY),
(1, 0, "Dangerous\n(loudest wins)",
results[("Low Info", "Advocacy")], ACCENT, "white"),
(1, 1, "Productive\n(surfaces assumptions)",
results[("Low Info", "Inquiry")], DARK_BG, "white"),
]
for row, col, label, mse, color, tc in cells:
rect = plt.Rectangle((col, 1 - row - 1), 1, 1, facecolor=color)
ax.add_patch(rect)
ax.text(col + 0.5, 1 - row - 0.40, label,
ha="center", va="center", fontsize=10, color=tc, fontweight="bold")
ax.text(col + 0.5, 1 - row - 0.72, f"MSE = {mse:.5f}",
ha="center", va="center", fontsize=9, color=tc, fontstyle="italic")
ax.set_xlim(0, 2)
ax.set_ylim(-1, 1)
ax.set_xticks([0.5, 1.5])
ax.set_xticklabels(["Advocacy", "Inquiry"], fontsize=11, fontweight="bold")
ax.set_yticks([-0.5, 0.5])
ax.set_yticklabels(["Low Information", "High Information"], fontsize=11, fontweight="bold")
ax.xaxis.tick_top()
ax.xaxis.set_label_position("top")
ax.set_xlabel("Decision Mode", fontsize=12, fontweight="bold", labelpad=10)
ax.set_ylabel("Information Level", fontsize=12, fontweight="bold")
for spine in ax.spines.values():
spine.set_visible(False)
ax.tick_params(length=0)
# White lines between cells
ax.axhline(0, color="white", linewidth=3)
ax.axvline(1, color="white", linewidth=3)
ax.set_title("When does each mode work?", fontsize=13, fontweight="bold", pad=30)
fig.tight_layout()
plt.show()
6. Pitfalls#
Advocacy feels productive but it is a battle of positions. It is a contest where the winner is determined by rhetorical skill, seniority, and stamina and not by evidence quality.
Shared information crowds out unique information. The hidden profile effect means groups over-discuss what everyone already knows and under-discuss what only one person knows. The more people already know something, the more attention it gets, even when the unshared information carries the decision-relevant signal.
Seniority does not equal signal quality. The CISO has broader context but not necessarily better data about this specific decision. The SOC analyst who pulled the logs may have the most informative estimate in the room. HiPPO weighting discounts the people closest to the evidence.
Pre-commitment is one of the cheapest debiasing techniques. Having each person write down their estimate before discussion costs zero dollars and five minutes. It prevents anchoring on each other’s numbers, reduces conformity pressure, and surfaces the true distribution of beliefs in the room.
Inquiry requires psychological safety. People will not surface unique information if they fear being judged for disagreeing with the group or contradicting the boss. The organizational precondition for inquiry is that being wrong is tolerated as the cost of honest exploration.