#!/usr/bin/env python3 """ CSI-IN v1.0 — Coach Selection Index (India) Reference implementation and weight-derivation engine. This file is the single source of truth for the CSI-IN weight vectors. METHODOLOGY.md, csi-spec-v1.0.json and index.html are all generated from, or checked against, the numbers this file produces. Run: python3 csi_reference.py # print all tables + self-test python3 csi_reference.py --json # rewrite csi-spec-v1.0.json python3 csi_reference.py --sens # weight sensitivity analysis No third-party dependencies. Pure integer/decimal arithmetic so that any re-implementation (JavaScript, Excel, R) reproduces identical output. """ import json, sys, hashlib from decimal import Decimal, ROUND_HALF_UP SPEC_VERSION = "1.0.0" SPEC_DATE = "2026-09-16" # --------------------------------------------------------------------------- # 1. PILLARS — the COACHES framework # --------------------------------------------------------------------------- # key letter name PILLARS = [ ("CTX", "C", "Context Fit"), ("OUT", "O", "Outcome Evidence"), ("AUTH", "A", "Authenticity & Chemistry"), ("CRED", "C", "Credential & Ethics Floor"), ("HRS", "H", "Hours & Practice Depth"), ("MTH", "E", "Explicit Method"), ("PRC", "S", "Straight Pricing"), ] PKEYS = [p[0] for p in PILLARS] # --------------------------------------------------------------------------- # 2. EVIDENCE MATRIX # --------------------------------------------------------------------------- # Raw percentages from ICF "Coaching Forward: South and Southeast Asia, # Trends 2025-26" (Kirtane, Kaur, Saksena et al., 2026), n=397. # # E5 = Exhibit 5, "Most important factors for selecting or working with a # coach (SPONSORS)", n = 38, multiple response. # E17 = Exhibit 17, "What coachees weigh when selecting a coach", n = 54, # multiple response. # # source codes: E5 / E17 = survey-derived # SPLIT = one survey item apportioned across two pillars # IMP = imputed from the other segment (option not offered) # EXCL = recorded but deliberately excluded CORPORATE_RAW = { # pillar : (value, source, provenance note) "HRS": (68.4, "E5", "Experience level 68.4%"), "CTX": (52.6, "E5", "Industry knowledge 52.6%"), "MTH": (47.4, "E5", "Clarity on approach 47.4%"), "AUTH": (44.7, "E5", "Authenticity 42.1% + executive presence 2.6%"), "CRED": (34.2, "E5", "Accreditation or certification 34.2%"), "OUT": (21.1, "E5", "References 21.1%"), "PRC": (21.1, "E5", "Fees 21.1%"), } PERSONAL_RAW = { "HRS": (99.1, "E17+SPLIT", "Experience 55.6% + half of coach expertise 87% (43.5)"), "AUTH": (120.4, "E17", "Chemistry/sample sessions 61.1% + authenticity 59.3%"), "MTH": (43.5, "E17/SPLIT", "Half of coach expertise 87% (43.5)"), "CRED": (22.2, "E17", "Credentials 22.2%"), "PRC": (20.4, "E17", "Fees 20.4%"), # CTX and OUT were not offered as options in Exhibit 17 -> imputed below. } # Items recorded by the study and deliberately NOT carried into the weights. DECLARED_EXCLUSIONS = [ ("Visibility", 2.6, "E5", "Ranked 8th of 9 by sponsors (2.6%). Excluded as a ranking parameter " "entirely: visibility measures marketing spend, not coaching capability, " "and including it would let reach buy rank."), ("Coachee readiness", 57.4, "E17", "Second-highest coachee factor, but an attribute of the BUYER, not the " "coach. Moved to the Engagement Readiness Check, which is scored " "separately and never enters a coach's CSI."), ("Organisational culture", 35.1, "E22", "Sponsor-side success factor; not coach-attributable."), ("Manager support", 21.6, "E22", "Sponsor-side success factor; not coach-attributable."), ] # --------------------------------------------------------------------------- # 3. DECLARED ADJUSTMENTS # --------------------------------------------------------------------------- # 3a. Evidence adjustment (delta). One published, tunable number. # # Justification: Exhibit 5 offered sponsors "References" as the only proxy for # demonstrated outcome, which understates how much the same sponsors say # evidence matters. In the same study sponsors weight business-impact analysis # at 41% and pre/post 360 at 41% (vs coaches' own practice at 19% and 17%), # and the report's own conclusion is that "measurement is the price of entry". # DELTA_EVIDENCE moves weight from Hours to Outcome Evidence to close that gap. # Set DELTA_EVIDENCE = 0.0 to run the model on pure revealed preference. DELTA_EVIDENCE = 6.0 # percentage points, HRS -> OUT # 3b. Leadership-level tilt. DECLARED JUDGEMENT, not survey-derived. # Each row sums to zero and no single entry exceeds +/- 5 percentage points. LEVELS = [ ("L1", "Emerging leader / first-time manager"), ("L2", "Mid-level manager"), ("L3", "Senior manager / function head"), ("L4", "SBU head / Director / VP"), ("L5", "CXO / C-suite"), ("L6", "Board / Chair / Promoter / Founder-CEO"), ] LEVEL_TILT = { # CTX OUT AUTH CRED HRS MTH PRC "L1": {"CTX":-1, "OUT":-2, "AUTH": 3, "CRED": 0, "HRS":-4, "MTH": 2, "PRC": 2}, "L2": {"CTX":-1, "OUT":-1, "AUTH": 2, "CRED": 0, "HRS":-2, "MTH": 1, "PRC": 1}, "L3": {"CTX": 2, "OUT": 1, "AUTH": 0, "CRED":-2, "HRS": 0, "MTH": 1, "PRC":-2}, "L4": {"CTX": 3, "OUT": 3, "AUTH":-1, "CRED":-3, "HRS": 1, "MTH": 0, "PRC":-3}, "L5": {"CTX": 2, "OUT": 4, "AUTH": 0, "CRED":-5, "HRS": 4, "MTH":-1, "PRC":-4}, "L6": {"CTX": 3, "OUT": 2, "AUTH": 1, "CRED":-5, "HRS": 5, "MTH":-1, "PRC":-5}, } LEVEL_TILT_RATIONALE = { "L1": "Fit and teachability dominate; deep CXO lineage is not yet relevant " "and budgets are tightest (often self-funded).", "L2": "As L1, moderated. The coach's method still matters more than their seniority.", "L3": "Functional depth begins to bite. Credential stops differentiating.", "L4": "Sector and level context, and demonstrated outcomes, become the " "decision. Fee stops being the constraint.", "L5": "Depth with senior populations and hard evidence peak. Every credible " "candidate already holds a credential, so credential stops discriminating.", "L6": "Governance context and practice depth peak; confidentiality is handled " "by the eligibility gate, not the score; fee is immaterial to the decision.", } WEIGHT_FLOOR = 2.0 # no pillar may fall below 2.0% after tilt # --------------------------------------------------------------------------- # 4. WEIGHT DERIVATION # --------------------------------------------------------------------------- def _norm(d): """Normalise a dict of floats to sum to 100.""" tot = sum(d.values()) return {k: v * 100.0 / tot for k, v in d.items()} def base_weights(segment): """Stage 1+2: harmonised, imputed, normalised base weights (pre-tilt).""" if segment == "corporate": raw = {k: v[0] for k, v in CORPORATE_RAW.items()} shares = _norm(raw) imputed = set() elif segment == "personal": avail = {k: v[0] for k, v in PERSONAL_RAW.items()} shares = _norm(avail) # normalise what we have corp = _norm({k: v[0] for k, v in CORPORATE_RAW.items()}) imputed = set() for k in PKEYS: # borrow missing shares if k not in shares: shares[k] = corp[k] imputed.add(k) shares = _norm(shares) # renormalise to 100 else: raise ValueError(segment) # Stage 2: declared evidence adjustment, HRS -> OUT shares["HRS"] -= DELTA_EVIDENCE shares["OUT"] += DELTA_EVIDENCE return shares, imputed def apply_tilt(base, level): """Stage 3: additive level tilt, floor clamp, proportional rebalance.""" tilt = LEVEL_TILT[level] w = {k: base[k] + tilt[k] for k in PKEYS} # Clamp to the floor, then reclaim the deficit proportionally from the # pillars that are above the floor. Iterate to a fixed point so the result # does not depend on evaluation order. for _ in range(50): deficit = sum(WEIGHT_FLOOR - w[k] for k in PKEYS if w[k] < WEIGHT_FLOOR) if deficit <= 1e-12: break for k in PKEYS: if w[k] < WEIGHT_FLOOR: w[k] = WEIGHT_FLOOR donors = {k: w[k] - WEIGHT_FLOOR for k in PKEYS if w[k] > WEIGHT_FLOOR} pool = sum(donors.values()) for k, headroom in donors.items(): w[k] -= deficit * headroom / pool return _norm(w) def round_to_100(w, dp=1): """Largest-remainder rounding so displayed weights sum to exactly 100.0.""" q = Decimal(10) ** -dp scaled = {k: Decimal(str(v)) / q for k, v in w.items()} floors = {k: int(v) for k, v in scaled.items()} remainder = int((Decimal(100) / q) - sum(floors.values())) order = sorted(PKEYS, key=lambda k: (-(scaled[k] - floors[k]), k)) for i in range(remainder): floors[order[i % len(order)]] += 1 return {k: float(Decimal(v) * q) for k, v in floors.items()} def weight_vector(segment, level): base, _ = base_weights(segment) return round_to_100(apply_tilt(base, level)) ALL_WEIGHTS = { f"{seg}:{lvl}": weight_vector(seg, lvl) for seg in ("personal", "corporate") for lvl, _ in LEVELS } # --------------------------------------------------------------------------- # 5. INDICATORS — 7 pillars x 3 indicators, each 0-4 on a countable anchor # --------------------------------------------------------------------------- INDICATORS = { "CTX": [ ("CTX1","Sector engagements","Documented coaching engagements in the buyer's sector in the last 5 years.", ["none","1","2-4","5-9","10 or more"]), ("CTX2","Level engagements","Documented engagements with coachees at the buyer's leadership level or above, last 5 years.", ["none","1","2-4","5-9","10 or more"]), ("CTX3","Operating lineage","The coach's own years in line, P&L or senior functional leadership before coaching.", ["none","1-4 years","5-9 years","10-19 years","20 or more years"]), ], "OUT": [ ("OUT1","Measurement design","The strongest outcome measure the coach agrees in writing before the engagement starts.", ["no written measure","coachee self-report only","+ structured stakeholder feedback","+ pre/post 360 or psychometric","+ business-impact analysis"]), ("OUT2","Documented results","Outcomes in the last 3 years attributable by a third party (promotion, retention, 360 delta, named business result).", ["none","1-2","3-5","6-10","11 or more"]), ("OUT3","Contactable references","References at or above the buyer's level the coach will release on request.", ["none","1","2","3","4 or more"]), ], "AUTH": [ ("AUTH1","Chemistry protocol","What the coach offers before any commitment.", ["nothing","a sales call","a free session of 30 min or less","a structured session of 45 min or more against a written brief","structured session plus two or more alternate coaches offered"]), ("AUTH2","Buyer-rated chemistry","THE BUYER'S OWN RATING after the chemistry session. The only judgement input in the model, and it belongs to the buyer, never to an AI.", ["I would not work with this person","tolerable","workable","I would look forward to the sessions","I felt understood and appropriately challenged"]), ("AUTH3","Claim integrity","Proportion of the coach's material public claims (hours, clients, results, credentials, awards) that can be independently verified.", ["material claims contradicted","most claims unverifiable","some verifiable","most verifiable","all material claims verifiable"]), ], "CRED": [ ("CRED1","Credential level","Highest current professional coaching credential.", ["none","non-accredited certificate","ICF ACC / EMCC Foundation or Practitioner","ICF PCC / EMCC Senior Practitioner","ICF MCC / EMCC Master Practitioner"]), ("CRED2","Supervision and CPD","Ongoing professional development and reflective supervision.", ["none","ad hoc","annual CPD requirement met","+ regular coaching supervision","+ documented supervision 6 or more times a year"]), ("CRED3","Ethics and indemnity","Written protections the coach signs up to.", ["nothing written","code of ethics only","+ written confidentiality undertaking","+ professional indemnity insurance","+ disclosed complaints and sanctions record"]), ], "HRS": [ ("HRS1","Documented coaching hours","Logged paid coaching hours, evidenced.", ["under 100","100-499","500-1,499","1,500-2,999","3,000 or more"]), ("HRS2","Years in practice","Years in professional coaching practice.", ["under 2","2-4","5-9","10-14","15 or more"]), ("HRS3","Repeat and extension rate","Share of engagements in the last 3 years that were repeat business or extended beyond the original contract.", ["under 10%","10-24%","25-39%","40-59%","60% or more"]), ], "MTH": [ ("MTH1","Named written method","How far the coach's approach is written down and testable before you buy.", ["nothing written","names a school or tradition","a written process of a page or more","a published multi-stage framework","+ a published evidence base with citations"]), ("MTH2","Contracting artefacts","What is agreed in writing at the start.", ["nothing","verbal agreement","written coaching agreement","+ three-way goal contracting with the sponsor","+ written mid-point review protocol"]), ("MTH3","Cadence and exit","How much of the engagement's shape is specified before it starts.", ["unspecified","duration only","duration and cadence","+ between-session protocol","+ written exit criteria"]), ], "PRC": [ ("PRC1","Fee transparency","When and how the fee becomes knowable.", ["not disclosed until late","range on request","written quote before the chemistry session","published fee band","+ published inclusions and exclusions"]), ("PRC2","Value rationality","Where the fee sits against the published band for this level, and what justifies it. Cheapest is NOT best.", ["outside band, unexplained","above band, no stated rationale","within band","within band with declared inclusions","declared inclusions plus an outcome-linked fee component"]), ("PRC3","Commercial terms","How many of these five are written: cancellation, confidentiality, IP, data handling, exit.", ["none","1","2-3","4","all 5"]), ], } # --------------------------------------------------------------------------- # 5b. THE ASK — each indicator rewritten as a request a buyer sends a coach # --------------------------------------------------------------------------- # A buyer cannot score what a coach has not given them. These are the twenty-one # requests that turn the codebook into an evidence request, in the buyer's voice. ASKS = { "CTX1": "How many coaching engagements have you completed in my sector in the last five years?", "CTX2": "How many of your coachees in the last five years were at my level or above?", "CTX3": "Before you coached, how many years did you spend in line, P&L or senior functional leadership?", "OUT1": "What outcome measure do you agree in writing before an engagement starts?", "OUT2": "In the last three years, what outcomes can a third party attribute to your coaching — promotions, retention, 360 movement, a named business result?", "OUT3": "How many references at my level or above will you release, and will you release them now?", "AUTH1": "What do you offer before any commitment — a sales call, a short free session, or a structured session against a written brief? Will alternates be offered if we do not fit?", "AUTH2": "(Nothing to send. This is my own rating after we have met.)", "AUTH3": "Which of your public claims — hours, clients, results, credentials, awards — can I verify independently, and where?", "CRED1": "What is your current coaching credential, and where is the register entry?", "CRED2": "How often are you supervised, and what CPD did you complete last year?", "CRED3": "Will you send your code of ethics, your written confidentiality undertaking, proof of professional indemnity insurance, and any complaints or sanctions on record?", "HRS1": "How many paid coaching hours have you logged, and what is the source of that log?", "HRS2": "In which year did you begin professional coaching practice?", "HRS3": "What share of your engagements in the last three years were repeat business or extended beyond the original contract?", "MTH1": "Do you have a written method — a named framework, a documented process, a published evidence base? Please send it.", "MTH2": "Will you send a blank coaching agreement, and do you do three-way goal contracting with the sponsor and a written mid-point review?", "MTH3": "Before we start, what will be specified — duration, cadence, between-session protocol, exit criteria?", "PRC1": "What is your fee, and what does it include and exclude?", "PRC2": "How does your fee sit against the market band for my level, what justifies it, and is any part of it linked to outcomes?", "PRC3": "Which of these are written into your agreement: cancellation, confidentiality, IP, data handling, exit?", } # --------------------------------------------------------------------------- # 6. EVIDENCE TIERS — the anti-puffery multiplier # --------------------------------------------------------------------------- EVIDENCE_TIERS = { "T1": (1.00, "Verified: a document, register entry or record the buyer can open."), "T2": (0.90, "Corroborated: a named, contactable third party or a dated public artefact."), "T3": (0.70, "Declared: the coach's own unverified claim."), "T4": (0.00, "Absent: no evidence offered. Scores zero regardless of claim."), } # --------------------------------------------------------------------------- # 7. ELIGIBILITY GATES — non-compensatory screening, applied before scoring # --------------------------------------------------------------------------- HOURS_FLOOR = {"L1":1, "L2":1, "L3":2, "L4":2, "L5":3, "L6":3} GATES = [ ("G1","CRED3 >= 2 for every buyer", "No written confidentiality undertaking, no listing. Not scorable, not rankable."), ("G2","HRS1 >= the floor for the buyer's level", "100+ hours at L1-L2, 500+ at L3-L4, 1,500+ at L5-L6."), ("G3","CTX2 >= 1 at L4 and above", "At least one documented engagement at or above the buyer's level."), ("G4","AUTH1 >= 2, else the CSI is capped at 70", "A coach who will not sit in a real chemistry session cannot reach Band A."), ("G5","OUT1 >= 1 for corporate-sponsored buyers, else capped at 70", "An organisation spending its own money is entitled to an agreed measure."), ] BANDS = [("A",80.0),("B",70.0),("C",60.0),("D",50.0),("E",0.0)] # --------------------------------------------------------------------------- # 7a. PUBLICATION RULES — only for register mode (a public league table) # --------------------------------------------------------------------------- # Scoring a coach for your own shortlist and publishing a score about a named # competitor are different acts with different duties. These rules apply only # to the second. # # The governing rule: ABSENCE OF EVIDENCE IS NOT EVIDENCE OF ABSENCE. In # shortlist mode a T4 (no evidence) scores zero, which is correct — the buyer # asked and got nothing. In register mode the coach was never asked, so a zero # would assert something the compiler cannot support. Below the coverage floor # an entry is therefore published as UNRATED, carries no score and no rank, and # is listed separately from the ranked table rather than at the bottom of it. PUBLICATION_KAPPA_FLOOR = 0.50 # minimum evidence coverage to publish a score PUBLICATION_MIN_RATED_INDICATORS = 14 # of 21, must carry T1/T2 evidence NOTICE_PERIOD_DAYS = 14 # notice to the coach before first publication def publication_status(result, codes, tiers): """rated | unrated — may this entry carry a public score and a rank?""" rated = sum(1 for i in tiers if tiers[i] in ("T1", "T2")) if result["coverage_kappa"] < PUBLICATION_KAPPA_FLOOR: return "unrated", (f"evidence coverage {result['coverage_kappa']:.0%} is below the " f"{PUBLICATION_KAPPA_FLOOR:.0%} floor required to publish a score") if rated < PUBLICATION_MIN_RATED_INDICATORS: return "unrated", (f"only {rated} of 21 indicators carry verifiable evidence; " f"{PUBLICATION_MIN_RATED_INDICATORS} are required") if not result["eligible"]: return "unrated", ("failed an eligibility gate on public evidence alone, which is not " "a finding about the coach — only about what is published") return "rated", "" def score_register(entries, segment, level): """Score a register file. Returns (ranked, unrated) — never one combined list. `entries` is a list of dicts: {name, codes, tiers, citations, notice_sent, reply_received}. Anything that cannot be published as a score lands in `unrated` with the reason stated, and unrated entries are NEVER ordered by score, because they do not have one. """ ranked_in, unrated = [], [] for e in entries: res = score_coach(e["codes"], e["tiers"], segment, level) status, reason = publication_status(res, e["codes"], e["tiers"]) if status == "rated": ranked_in.append({"name": e["name"], "result": res, "entry": e}) else: unrated.append({"name": e["name"], "reason": reason, "entry": e, "coverage_kappa": res["coverage_kappa"]}) unrated.sort(key=lambda u: u["name"].casefold()) # alphabetical, never by score return rank(ranked_in), unrated def score_coach(codes, tiers, segment, level, delta=None): """ codes : {indicator_id: 0..4} tiers : {indicator_id: "T1".."T4"} Returns the full, auditable result. Pure arithmetic - no judgement. """ w = weight_vector(segment, level) if delta is None else None if delta is not None: global DELTA_EVIDENCE keep, DELTA_EVIDENCE = DELTA_EVIDENCE, delta w = weight_vector(segment, level) DELTA_EVIDENCE = keep pillar_scores, covered, total_w = {}, 0.0, 0.0 for pkey in PKEYS: ids = [i[0] for i in INDICATORS[pkey]] acc = 0.0 for iid in ids: raw = codes.get(iid, 0) tier = tiers.get(iid, "T4") mult = EVIDENCE_TIERS[tier][0] acc += (raw / 4.0) * mult if tier in ("T1", "T2"): covered += w[pkey] / len(ids) total_w += w[pkey] / len(ids) pillar_scores[pkey] = 100.0 * acc / len(ids) csi = sum(w[p] * pillar_scores[p] for p in PKEYS) / 100.0 # gates fails, caps = [], [] if codes.get("CRED3", 0) < 2: fails.append("G1") if codes.get("HRS1", 0) < HOURS_FLOOR[level]: fails.append("G2") if level in ("L4","L5","L6") and codes.get("CTX2", 0) < 1: fails.append("G3") if codes.get("AUTH1", 0) < 2: caps.append("G4") if segment == "corporate" and codes.get("OUT1", 0) < 1: caps.append("G5") eligible = not fails if caps: csi = min(csi, 70.0) kappa = covered / total_w if total_w else 0.0 band_halfwidth = (1.0 - kappa) * 12.0 csi_r = float(Decimal(str(csi)).quantize(Decimal("0.1"), ROUND_HALF_UP)) band = next(b for b, lo in BANDS if csi_r >= lo) return { "eligible": eligible, "gates_failed": fails, "gates_capped": caps, "pillars": {p: round(pillar_scores[p], 1) for p in PKEYS}, "weights": w, "csi": csi_r, "coverage_kappa": round(kappa, 3), "confidence_halfwidth": round(band_halfwidth, 1), "csi_low": round(max(0.0, csi_r - band_halfwidth), 1), "csi_high": round(min(100.0, csi_r + band_halfwidth), 1), "band": band if eligible else "INELIGIBLE", } def rank(results): """Deterministic banded ranking. Ties broken without randomness.""" elig = [r for r in results if r["result"]["eligible"]] elig.sort(key=lambda r: ( -r["result"]["csi"], -r["result"]["coverage_kappa"], -r["result"]["pillars"]["OUT"], -r["result"]["pillars"]["HRS"], r["name"].casefold(), )) # Coaches whose confidence intervals overlap share a rank position. out, pos, i = [], 1, 0 while i < len(elig): grp = [elig[i]] while (i + len(grp) < len(elig) and elig[i + len(grp)]["result"]["csi_high"] >= elig[i]["result"]["csi_low"]): grp.append(elig[i + len(grp)]) for g in grp: out.append({**g, "position": pos, "shared": len(grp) > 1}) pos += len(grp); i += len(grp) return out # --------------------------------------------------------------------------- # 8. REPORTING # --------------------------------------------------------------------------- def _fmt_table(rows, headers): widths = [max(len(str(r[i])) for r in [headers] + rows) for i in range(len(headers))] line = lambda r: "| " + " | ".join(str(c).ljust(widths[i]) for i, c in enumerate(r)) + " |" sep = "|" + "|".join("-" * (w + 2) for w in widths) + "|" return "\n".join([line(headers), sep] + [line(r) for r in rows]) def print_weight_tables(): for seg in ("personal", "corporate"): base, imputed = base_weights(seg) print(f"\n### Base weights before level tilt — {seg.upper()}\n") src = CORPORATE_RAW if seg == "corporate" else PERSONAL_RAW rows = [] for k in PKEYS: if k in src: _, s, note = src[k] else: s, note = "IMP", "not offered in this segment's survey; borrowed from the other segment" rows.append([k, dict((p[0], p[2]) for p in PILLARS)[k], f"{base[k]:.2f}", s, note]) print(_fmt_table(rows, ["Key", "Pillar", "Weight %", "Source", "Provenance"])) print(f"\nSum = {sum(base.values()):.2f} " f"(includes declared evidence adjustment of {DELTA_EVIDENCE:+.1f}pp HRS→OUT)") for seg in ("personal", "corporate"): print(f"\n### Final weights by leadership level — {seg.upper()}\n") rows = [] for lvl, label in LEVELS: w = weight_vector(seg, lvl) rows.append([lvl, label] + [f"{w[k]:.1f}" for k in PKEYS] + [f"{sum(w.values()):.1f}"]) print(_fmt_table(rows, ["Lvl", "Leadership level"] + PKEYS + ["Sum"])) def sensitivity(): """How far does DELTA_EVIDENCE move the answer? Rank stability check.""" global DELTA_EVIDENCE keep = DELTA_EVIDENCE print("\n### Sensitivity of pillar weights to DELTA_EVIDENCE (corporate, L5)\n") rows = [] for d in (0, 2, 4, 6, 8, 10, 12): DELTA_EVIDENCE = float(d) w = weight_vector("corporate", "L5") rows.append([f"{d:+d}"] + [f"{w[k]:.1f}" for k in PKEYS]) DELTA_EVIDENCE = keep print(_fmt_table(rows, ["Δ pp"] + PKEYS)) print("\n### Rank stability of the worked example across Δ = 0…12\n") rows = [] for d in (0, 2, 4, 6, 8, 10, 12): res = [{"name": n, "result": score_coach(c, t, "corporate", "L5", delta=float(d))} for n, c, t in EXAMPLES] order = " > ".join(r["name"] for r in rank(res)) rows.append([f"{d:+d}", order]) print(_fmt_table(rows, ["Δ pp", "Resulting order"])) # --------------------------------------------------------------------------- # 9. WORKED EXAMPLE — three synthetic coaches, used as the conformance fixture # --------------------------------------------------------------------------- def _mk(vals, tier_map): ids = [i[0] for p in PKEYS for i in INDICATORS[p]] codes = dict(zip(ids, vals)) tiers = {i: tier_map.get(i, "T3") for i in ids} return codes, tiers # order: CTX1-3, OUT1-3, AUTH1-3, CRED1-3, HRS1-3, MTH1-3, PRC1-3 _A = _mk([3,4,4, 4,3,3, 3,4,4, 3,3,3, 3,4,3, 4,4,3, 4,4,4], {i: "T1" for i in ["CTX1","CTX2","OUT1","OUT2","CRED1","CRED2","CRED3", "HRS1","HRS2","MTH1","MTH2","PRC1","PRC3","AUTH1"]}) _B = _mk([4,2,2, 2,1,2, 4,3,3, 4,4,3, 4,4,2, 2,3,2, 2,2,3], {i: "T1" for i in ["CTX1","CRED1","CRED2","CRED3","HRS1","HRS2","AUTH1"]}) _C = _mk([1,1,1, 0,0,1, 1,3,1, 4,2,1, 2,3,1, 1,1,1, 1,1,1], {i: "T1" for i in ["CRED1"]}) EXAMPLES = [ ("Coach A — evidence-led practitioner", _A[0], _A[1]), ("Coach B — senior but lightly documented", _B[0], _B[1]), ("Coach C — credentialled, little else verifiable", _C[0], _C[1]), ] def self_test(): print("\n### Worked example — corporate sponsor, L5 (CXO)\n") res = [{"name": n, "result": score_coach(c, t, "corporate", "L5")} for n, c, t in EXAMPLES] rows = [] for r in rank(res): d = r["result"] rows.append([r["position"], r["name"], f"{d['csi']:.1f}", f"{d['csi_low']:.1f}–{d['csi_high']:.1f}", d["band"], f"{d['coverage_kappa']:.2f}", ",".join(d["gates_capped"]) or "—"]) print(_fmt_table(rows, ["#", "Coach", "CSI", "Confidence", "Band", "κ", "Capped"])) for r in res: if not r["result"]["eligible"]: print(f" INELIGIBLE: {r['name']} — failed {r['result']['gates_failed']}") print("\n### Same three coaches, personal buyer at L1 (first-time manager)\n") res2 = [{"name": n, "result": score_coach(c, t, "personal", "L1")} for n, c, t in EXAMPLES] rows = [] for r in rank(res2): d = r["result"] rows.append([r["position"], r["name"], f"{d['csi']:.1f}", d["band"]]) print(_fmt_table(rows, ["#", "Coach", "CSI", "Band"])) print("\nThe order can legitimately change between buyer profiles. That is the " "point of the instrument: there is no single best coach in India, only a " "best-evidenced coach for a stated buyer at a stated level.") # invariants for seg in ("personal", "corporate"): for lvl, _ in LEVELS: w = weight_vector(seg, lvl) assert abs(sum(w.values()) - 100.0) < 1e-9, (seg, lvl, sum(w.values())) assert min(w.values()) >= WEIGHT_FLOOR - 1e-9, (seg, lvl) for lvl, _ in LEVELS: assert sum(LEVEL_TILT[lvl].values()) == 0, lvl assert max(abs(v) for v in LEVEL_TILT[lvl].values()) <= 5, lvl print("\nInvariants OK: 12/12 weight vectors sum to 100.0, respect the " f"{WEIGHT_FLOOR}% floor, and every tilt row is zero-sum and within ±5pp.") # --------------------------------------------------------------------------- # 10. SPEC EMISSION # --------------------------------------------------------------------------- def build_spec(): return { "spec": "CSI-IN", "version": SPEC_VERSION, "date": SPEC_DATE, "title": "Coach Selection Index (India) — buyer-operated ranking specification", "licence": "CC BY 4.0. Free to use, replicate, criticise and fork with attribution.", "determinism": { "normalisation": "absolute-anchored", "note": ("Scores are NEVER normalised against the candidate set. No min-max, " "no z-score, no entropy or TOPSIS weighting. A coach's score is " "identical whether they are compared with two rivals or two hundred. " "This is what makes the index reproducible across platforms and " "stable over time."), "arithmetic": "performed outside the language model by published code", "llm_role": "evidence extraction against a fixed codebook only; never scoring, never arithmetic", "decoding": {"temperature": 0, "top_p": 1, "output": "strict JSON, fixed key order"}, "adjudication": "on disagreement between platforms, the LOWEST code wins and the disagreement is logged", }, "pillars": [{"key": k, "letter": l, "name": n, "indicators": [{"id": i[0], "name": i[1], "question": i[2], "anchors": i[3], "ask": ASKS[i[0]]} for i in INDICATORS[k]]} for k, l, n in PILLARS], "within_pillar_weighting": "equal (1/3 each); unit weights per Dawes (1979)", "evidence_tiers": {k: {"multiplier": v[0], "definition": v[1]} for k, v in EVIDENCE_TIERS.items()}, "gates": [{"id": g[0], "rule": g[1], "rationale": g[2]} for g in GATES], "hours_floor_by_level": HOURS_FLOOR, "bands": {b: lo for b, lo in BANDS}, "publication": { "applies_to": "register mode only — a published league table of named coaches", "kappa_floor": PUBLICATION_KAPPA_FLOOR, "min_rated_indicators": PUBLICATION_MIN_RATED_INDICATORS, "notice_period_days": NOTICE_PERIOD_DAYS, "absence_rule": ("Absence of evidence is not evidence of absence. An entry below " "the floor publishes as UNRATED with no score and no rank, listed " "separately and alphabetically. A zero is never published as a " "finding about a coach who was never asked."), "unrated_ordering": "alphabetical — never by score, which does not exist for them", }, "levels": [{"id": i, "label": l, "tilt": LEVEL_TILT[i], "rationale": LEVEL_TILT_RATIONALE[i]} for i, l in LEVELS], "delta_evidence": DELTA_EVIDENCE, "weight_floor": WEIGHT_FLOOR, "declared_exclusions": [{"item": e[0], "survey_value": e[1], "source": e[2], "reason": e[3]} for e in DECLARED_EXCLUSIONS], "evidence_base": { "primary": ("ICF Coaching Forward: State of Executive Coaching in South and " "Southeast Asia, Trends 2025-26. Kirtane, Kaur, Saksena, Bhowmick " "et al. n=397 (306 coaches, 54 coachees, 37-38 sponsors). Ethical " "approval: Henley Business School. Fieldwork 15 Aug - 11 Nov 2025."), "exhibits_used": ["Exhibit 5 (sponsor selection factors, n=38)", "Exhibit 17 (coachee selection factors, n=54)", "Exhibit 22 (success factors, n=37)", "Exhibit 3 (impact measurement, coaches vs sponsors)"], }, "weights": ALL_WEIGHTS, "conformance_fixture": { "description": ("Any re-implementation must reproduce these numbers exactly. " "If it does not, it is not CSI-IN."), "cases": [ {"name": n, "segment": "corporate", "level": "L5", "codes": c, "tiers": t, "expected": {kk: vv for kk, vv in score_coach(c, t, "corporate", "L5").items() if kk in ("csi", "band", "coverage_kappa", "eligible")}} for n, c, t in EXAMPLES ], }, } def spec_hash(spec): return hashlib.sha256( json.dumps(spec, sort_keys=True, separators=(",", ":")).encode() ).hexdigest() def print_register(path): """Render a register file exactly as it must publish. See REGISTER-PROTOCOL.md.""" reg = json.load(open(path)) live = [e for e in reg["entries"] if not e.get("excluded_by_request")] ranked, unrated = score_register(live, reg["segment"], reg["level"]) print(f"\nCSI-IN register — edition {reg['edition']} · {reg['segment']}:{reg['level']} " f"· compiled {reg['compiled']}\n") rows = [] for r in ranked: d, e = r["result"], r["entry"] rows.append([f"{'=' if r['shared'] else ''}{r['position']}", r["name"], f"{d['csi']:.1f}", f"{d['csi_low']:.1f}–{d['csi_high']:.1f}", d["band"], f"{d['coverage_kappa']:.2f}", e.get("codes_set_by", "compiler"), e.get("commercial_relationship", "none"), "yes" if e.get("reply_received") else "no reply"]) print(_fmt_table(rows, ["#", "Coach", "CSI", "Confidence", "Band", "κ", "Codes set by", "Paid relationship", "Replied"]) if rows else " (no rated entries)") if unrated: print("\nInsufficient public evidence to score — no score, no rank, alphabetical.") print("This records the state of the published record, not a finding about the coach.\n") print(_fmt_table([[u["name"], u["reason"]] for u in unrated], ["Coach", "Why unrated"])) excluded = [e["name"] for e in reg["entries"] if e.get("excluded_by_request")] if excluded: print("\nAsked not to be included (honoured without argument):") for n in excluded: print(f" · {n}") missing = [e["name"] for e in reg["entries"] if not e.get("notice_sent")] if missing: print(f"\n!! NOT PUBLISHABLE — no notice sent to: {', '.join(missing)}") print(f" Every entry needs {NOTICE_PERIOD_DAYS} days' written notice before first " f"publication (REGISTER-PROTOCOL.md §5).") uncited = [] for e in reg["entries"]: for i, c in e.get("codes", {}).items(): if c > 0 and i not in e.get("citations", {}): uncited.append(f"{e['name']}:{i}") if uncited: print(f"\n!! NOT PUBLISHABLE — codes above zero with no citation: {', '.join(uncited)}") print(" An uncited code is not a low code. It is not a code at all (§3).") # METHODOLOGY §12.9 rule 2: money in the relationship removes the compiler from the coding. conflicted = [e["name"] for e in reg["entries"] if e.get("commercial_relationship", "none") != "none" and e.get("codes_set_by") != "adjudicator"] if conflicted: print(f"\n!! NOT PUBLISHABLE — paying or recently paying clients of the compiler whose " f"codes the compiler set: {', '.join(conflicted)}") print(" A commercial relationship forces codes_set_by = 'adjudicator' (§12.9 rule 2).") undisclosed = [e["name"] for e in reg["entries"] if "commercial_relationship" not in e] if undisclosed: print(f"\n!! NOT PUBLISHABLE — commercial relationship not declared for: " f"{', '.join(undisclosed)}") print(" Every entry states it, including 'none' (§12.9 rule 3).") if not reg.get("adjudicator", {}).get("name", "").strip() or \ "TO BE APPOINTED" in reg.get("adjudicator", {}).get("name", ""): print("\n!! NOT PUBLISHABLE — no independent adjudicator appointed (§8.2).") if __name__ == "__main__": if "--register" in sys.argv: print_register(sys.argv[sys.argv.index("--register") + 1]) elif "--json" in sys.argv: spec = build_spec() spec["sha256"] = spec_hash(spec) with open("csi-spec-v1.0.json", "w") as f: json.dump(spec, f, indent=2, ensure_ascii=False) f.write("\n") print(f"csi-spec-v1.0.json written. sha256={spec['sha256']}") elif "--sens" in sys.argv: sensitivity() else: print(f"CSI-IN v{SPEC_VERSION} — reference implementation") print_weight_tables() self_test() sensitivity() print(f"\nSpec SHA-256: {spec_hash(build_spec())}")