"""Deterministic BDEF scoring — pure functions, stdlib only, no I/O. Everything numeric happens here, never in a model: the panel supplies 1-5 category scores with verbatim evidence, the extractor supplies KPI actuals and targets, and this module turns them into the 0-100 composite: composite = quant (60) + qualitative (40) - red-flag penalties (cap 15) All knobs come from the `weights` dict (bm_config.WEIGHTS_DEFAULTS shape) so the operator can retune without a rebuild. """ from __future__ import annotations import difflib import re import statistics FUZZY_THRESHOLD = 0.85 _CATEGORIES = "ABCDEFGH" # ---------------------------------------------------------------- KPI matching def _norm(name: str) -> str: return re.sub(r"[^a-z0-9]+", " ", (name or "").lower()).strip() def match_kpi(canonical: str, candidates: list[dict], aliases: dict) -> tuple[dict | None, str | None]: """Match a canonical KPI name against candidate dicts ({canonical_name, name}). Exact canonical -> alias map (either direction) -> fuzzy ratio >= 0.85. Returns (candidate, via) with via in exact|alias|fuzzy, or (None, None).""" canon = (canonical or "").strip().lower() if not canon: return None, None for c in candidates: if (c.get("canonical_name") or "").strip().lower() == canon: return c, "exact" amap = {(k or "").strip().lower(): {(a or "").strip().lower() for a in (v or [])} for k, v in (aliases or {}).items()} ours = amap.get(canon, set()) for c in candidates: cn = (c.get("canonical_name") or "").strip().lower() nm = (c.get("name") or "").strip().lower() if cn in ours or nm in ours or canon in amap.get(cn, set()): return c, "alias" best, best_r = None, 0.0 for c in candidates: for other in (c.get("canonical_name") or "", c.get("name") or ""): r = difflib.SequenceMatcher(None, _norm(canon), _norm(other)).ratio() if r > best_r: best, best_r = c, r if best is not None and best_r >= FUZZY_THRESHOLD: return best, "fuzzy" return None, None # ---------------------------------------------------------------- KPI credit def _passes(actual: float, target: float, direction: str) -> bool: return actual <= target if direction == "lte" else actual >= target def _credit(actual: float, target: float, direction: str, floor: float) -> float: """Partial credit for a targeted KPI: 0 below floor, linear to 1 at target.""" if target == 0 or (actual < 0) != (target < 0) or (direction == "lte" and actual == 0): return 1.0 if _passes(actual, target, direction) else 0.0 r = target / actual if direction == "lte" else actual / target if actual < 0 and target < 0: # Both negative (EBITDA margin target -2, actual -3): the plain ratio # inverts the ordering, so flip it back. r = 1.0 / r if r >= 1: return 1.0 if floor >= 1 or r < floor: return 0.0 return max(0.0, min(1.0, (r - floor) / (1 - floor))) def _resolve_target(kpi: dict, pinned_targets: list[dict], prior_targets: list[dict], aliases: dict) -> tuple[dict | None, str | None, str | None]: """(target dict {target, direction}, target_source, matched_via) for one KPI. Precedence: pinned config target > prior deck's extracted forward target for this period > target printed in the deck itself.""" canon = kpi.get("canonical_name") or "" pinned_cands = [{"canonical_name": p.get("kpi"), "name": p.get("kpi"), "_src": p} for p in (pinned_targets or [])] cand, via = match_kpi(canon, pinned_cands, aliases) if cand is not None: p = cand["_src"] return {"target": p.get("target"), "direction": p.get("direction") or kpi.get("direction"), "profitability": p.get("profitability")}, "pinned", via cand, via = match_kpi(canon, prior_targets or [], aliases) if cand is not None: return {"target": cand.get("target"), "direction": cand.get("direction") or kpi.get("direction")}, "extracted", via if kpi.get("target_in_deck") is not None: return {"target": kpi["target_in_deck"], "direction": kpi.get("direction")}, "in_deck", None return None, None, None # ---------------------------------------------------------------- scoring def _bucket(results: list[dict], weight: float) -> dict: if not results: return {"score": 0.0, "weight": 0.0, "na": True, "kpi_count": 0} mean = sum(r["credit"] for r in results) / len(results) return {"score": mean, "weight": weight, "na": False, "kpi_count": len(results)} def _qual(grades: list[dict], weights: dict) -> tuple[float, dict]: """(qual score, per-category detail). Evidence regresses medians toward 3.""" full_credit = max(1, int(weights.get("evidenceFullCredit", 400))) cat_max = float(weights.get("qualCategoryMax", 5)) out: dict[str, dict] = {} total = 0.0 for cid in _CATEGORIES: scores: list[int] = [] equalities: list[float] = [] rationales: list[dict] = [] for g in grades or []: cat = next((c for c in (g.get("categories") or []) if c.get("id") == cid), None) if cat is None: continue evidence = cat.get("evidence") or [] quote_chars = sum(min(len(ev.get("quote") or ""), 200) for ev in evidence) scores.append(int(cat.get("score", 3))) equalities.append(min(1.0, quote_chars / full_credit)) rationales.append({ "grader": g.get("grader") or "grader", "rationale": cat.get("rationale") or "", "evidence": [{"quote": ev.get("quote") or "", "location": ev.get("location") or ""} for ev in evidence], }) if scores: median = float(statistics.median(scores)) e_mean = sum(equalities) / len(equalities) else: median, e_mean = 3.0, 0.0 adjusted = 3.0 + (median - 3.0) * e_mean points = adjusted * cat_max / 5.0 total += points out[cid] = { "panel_scores": scores, "median": round(median, 2), "evidence_quality": round(e_mean, 4), "adjusted": round(adjusted, 4), "points": round(points, 4), "rationales": rationales, } return total, out def score_deck(extraction: dict, grades: list[dict], pinned_targets: list[dict], prior_targets: list[dict], kpi_aliases: dict, weights: dict, meta: dict) -> dict: """Score one graded deck into the canonical ledger record. Pure.""" kpis = extraction.get("kpis") or [] floor = float(weights.get("kpiCreditFloor", 0.5)) scoring_flags: list[dict] = [] # --- per-KPI target resolution + credit kpi_results: list[dict] = [] for k in kpis: tgt, source, via = _resolve_target(k, pinned_targets, prior_targets, kpi_aliases) credit = None target = None if tgt is not None and tgt.get("target") is not None: target = float(tgt["target"]) credit = _credit(float(k.get("actual", 0)), target, tgt.get("direction") or k.get("direction") or "gte", floor) # The operator's pinned classification beats the extractor's guess — # a pinned profitability KPI must score in the profitability bucket. profitability = bool(k.get("profitability")) if tgt is not None and tgt.get("profitability") is not None: profitability = bool(tgt["profitability"]) kpi_results.append({ "canonical_name": k.get("canonical_name"), "name": k.get("name"), "actual": k.get("actual"), "unit": k.get("unit") or "", "direction": k.get("direction"), "profitability": profitability, "target": target, "target_source": source, "matched_via": via, "credit": None if credit is None else round(credit, 4), }) prof_all = [r for r in kpi_results if r["profitability"]] prof_hit = [r for r in prof_all if r["credit"] is not None] other_hit = [r for r in kpi_results if not r["profitability"] and r["credit"] is not None] # --- forecast integrity: this deck's actuals vs the prior deck's targets forecast_results: list[dict] = [] dropped: list[str] = [] for pt in prior_targets or []: cand, _via = match_kpi(pt.get("canonical_name") or "", kpis, kpi_aliases) if cand is None: dropped.append(pt.get("canonical_name") or pt.get("name") or "kpi") continue actual = float(cand.get("actual", 0)) target = float(pt.get("target", 0)) direction = pt.get("direction") or "gte" if target == 0: acc = 1.0 if _passes(actual, target, direction) else 0.0 else: err = (actual - target) / abs(target) if direction == "lte": err = -err e = abs(err) if err < 0 else abs(err) / 2.0 # overshoot penalized half acc = 1.0 - min(1.0, e) forecast_results.append({ "canonical_name": pt.get("canonical_name"), "target": target, "actual": actual, "accuracy": round(acc, 4), }) # Pinned targets that no reported actual matches count as dropped too. for p in pinned_targets or []: cand, _via = match_kpi(p.get("kpi") or "", kpis, kpi_aliases) if cand is None: dropped.append(p.get("kpi") or "kpi") seen: set[str] = set() dropped_unique = [d for d in dropped if not (d.strip().lower() in seen or seen.add(d.strip().lower()))] for name in dropped_unique[: int(weights.get("droppedKpiMax", 3))]: scoring_flags.append({ "code": "kpi_dropped", "description": f"previously targeted KPI '{name}' is not reported this period", "severity": int(weights.get("droppedKpiPenalty", 2)), }) # --- quant buckets + renormalization (NA weight redistributes pro-rata) prof = _bucket(prof_hit, float(weights.get("profitabilityKpi", 30))) other = _bucket(other_hit, float(weights.get("otherKpi", 20))) fmean = (sum(f["accuracy"] for f in forecast_results) / len(forecast_results) if forecast_results else 0.0) if forecast_results: forecast = {"score": fmean, "weight": float(weights.get("forecastIntegrity", 10)), "na": False, "kpi_count": len(forecast_results)} else: forecast = {"score": 0.0, "weight": 0.0, "na": True, "kpi_count": 0} if not prof_all: prof["na"] = True prof["weight"] = 0.0 scoring_flags.append({ "code": "no_profitability_visibility", "description": "no profit/margin/cash KPI reported at all", "severity": 3, }) total_quant_w = (float(weights.get("profitabilityKpi", 30)) + float(weights.get("otherKpi", 20)) + float(weights.get("forecastIntegrity", 10))) present = [b for b in (prof, other, forecast) if not b["na"]] if present: scale = total_quant_w / sum(b["weight"] for b in present) for b in present: b["weight"] = round(b["weight"] * scale, 4) b["score"] = round(b["score"] * b["weight"], 4) quant_score = sum(b["score"] for b in present) all_quant_na = False else: quant_score = 0.0 all_quant_na = True scoring_flags.append({ "code": "no_quantitative_kpis", "description": "no quantitative bucket could be scored (no targeted KPIs, " "no prior targets)", "severity": 4, }) # --- qualitative qual_score, categories = _qual(grades, weights) qual_max = 8.0 * float(weights.get("qualCategoryMax", 5)) # --- red flags: extractor + graders + scoring; dedup, damp single-source damp = float(weights.get("singleSourceFlagFactor", 0.5)) cap = float(weights.get("redFlagCap", 15)) flag_map: dict[str, dict] = {} def add_flag(f: dict, source: str, scoring_flag: bool = False): code = (f.get("code") or "flag").strip().lower() key = f"{code}:{f.get('description', '')}" if scoring_flag and code == "kpi_dropped" else code sev = int(f.get("severity", 1)) cur = flag_map.get(key) if cur is None: flag_map[key] = {"code": code, "description": f.get("description") or "", "severity": sev, "sources": {source}, "scoring": scoring_flag} else: if sev > cur["severity"]: cur["severity"] = sev cur["description"] = f.get("description") or cur["description"] cur["sources"].add(source) cur["scoring"] = cur["scoring"] or scoring_flag for f in extraction.get("red_flag_candidates") or []: add_flag(f, "extractor") for g in grades or []: for f in g.get("red_flags") or []: add_flag(f, g.get("grader") or "grader") for f in scoring_flags: add_flag(f, "scoring", scoring_flag=True) flags: list[dict] = [] for f in flag_map.values(): full = f["scoring"] or len(f["sources"]) >= 2 points = float(f["severity"]) if full else float(f["severity"]) * damp flags.append({"code": f["code"], "description": f["description"], "severity": f["severity"], "points": round(points, 4), "sources": sorted(f["sources"])}) flags.sort(key=lambda f: (-f["points"], f["code"])) penalty_total = round(min(cap, sum(f["points"] for f in flags)), 4) # --- composite if all_quant_na: base = (qual_score / qual_max * 100.0) if qual_max else 0.0 else: base = quant_score + qual_score composite = round(max(0.0, min(100.0, base - penalty_total)), 1) return { "schema_version": 1, "company": meta.get("company"), "period": meta.get("period"), "deck_id": meta.get("deck_id"), "job_id": meta.get("job_id"), "graded_at": meta.get("graded_at"), "composite": composite, "quant": {"score": round(quant_score, 4), "profitability": prof, "other": other, "forecast_integrity": forecast}, "qual": {"score": round(qual_score, 4), "categories": categories}, "penalties": {"total": penalty_total, "flags": flags}, "kpi_results": kpi_results, "forecast_results": forecast_results, "panel": meta.get("panel") or [], "artifacts": meta.get("artifacts", {}), "narrative": extraction.get("narrative", {}), }