v0.1.9: per-company progress reviews + deck-quality guidance
progress.py reads the whole graded ledger and answers two questions the
per-deck scorecard can't: is the company actually progressing (composite/
BDEF-category/KPI trajectories, recurring vs resolved flags), and is the
material good enough to judge them by — a deterministic gap engine spots
what the decks are NOT showing (no profitability visibility, untargeted
KPIs, no forward guidance, broken forecast chain, silently dropped KPIs,
thin-evidence BDEF categories, no board asks) and renders each gap as a
concrete, paste-ready request for the next deck.
Served live from the ledger (no GPU) at /api/companies/{slug}/progress(.md),
written to /data/ledger/<slug>/PROGRESS.md + /data/reports/latest-progress.md
after each graded deck, and viewable/downloadable from the dashboard company
card ("View progress review"). 18 new tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
506e6c79bd
commit
9b9c7e58c1
@@ -120,7 +120,7 @@ Canonical repo: `https://gitea.ten31.ai/Ten31AI/boardroom-map`.
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
**v0.1.8 — live in production.** Deployed on a StartOS box driving a DGX Spark
|
**v0.1.9.** Deployed on a StartOS box driving a DGX Spark
|
||||||
in single-spark air-gapped mode (gemma-4-31B panel: munger-lens /
|
in single-spark air-gapped mode (gemma-4-31B panel: munger-lens /
|
||||||
girdley-operator / buffett-owner). First full grading run completed
|
girdley-operator / buffett-owner). First full grading run completed
|
||||||
2026-07-29: a three-deck company history graded end-to-end into a running
|
2026-07-29: a three-deck company history graded end-to-end into a running
|
||||||
@@ -142,7 +142,15 @@ scratch with a different model panel; v0.1.8 added the two-Spark pipeline —
|
|||||||
secondary-Spark models work in air-gapped mode (dual-homed proxy; graders
|
secondary-Spark models work in air-gapped mode (dual-homed proxy; graders
|
||||||
keep zero egress), extraction and grading run in parallel when their models
|
keep zero egress), extraction and grading run in parallel when their models
|
||||||
sit on different Sparks, and single-wave jobs keep the vLLMs warm across
|
sit on different Sparks, and single-wave jobs keep the vLLMs warm across
|
||||||
decks instead of reloading the 31B (~6 min) per deck.
|
decks instead of reloading the 31B (~6 min) per deck; v0.1.9 added per-company
|
||||||
|
progress reviews — `PROGRESS.md` / "View progress review" reads the whole
|
||||||
|
graded ledger for the trajectory (composite deltas, improving/declining BDEF
|
||||||
|
categories and KPIs, recurring vs resolved flags) plus a deterministic
|
||||||
|
deck-quality gap engine that lists what the materials aren't showing (no
|
||||||
|
profitability visibility, untargeted KPIs, no forward guidance, dropped KPIs,
|
||||||
|
thin-evidence categories, no board asks) and renders them as a paste-ready
|
||||||
|
request list for the next deck (`/api/companies/{slug}/progress(.md)`, no GPU
|
||||||
|
needed).
|
||||||
|
|
||||||
Known optimization not yet done: the wave is torn down per deck, so the 31B
|
Known optimization not yet done: the wave is torn down per deck, so the 31B
|
||||||
reloads from disk (~6 min) between decks even when the model set is unchanged.
|
reloads from disk (~6 min) between decks even when the model set is unchanged.
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import decks
|
|||||||
import graders as grader_mod
|
import graders as grader_mod
|
||||||
import jobs
|
import jobs
|
||||||
import ledger as ledger_mod
|
import ledger as ledger_mod
|
||||||
|
import progress as progress_mod
|
||||||
import serving
|
import serving
|
||||||
|
|
||||||
DATA_DIR = os.environ.get("BM_DATA_DIR", "/data")
|
DATA_DIR = os.environ.get("BM_DATA_DIR", "/data")
|
||||||
@@ -406,6 +407,25 @@ def delete_company(slug: str, restore_decks: bool = False):
|
|||||||
return {"ok": True, "removed": removed, "restored_decks": restored}
|
return {"ok": True, "removed": removed, "restored_decks": restored}
|
||||||
|
|
||||||
|
|
||||||
|
def _progress_analysis(slug: str) -> dict:
|
||||||
|
"""Live progress analysis over the ledger (never stale, no file needed)."""
|
||||||
|
led = ledger_mod.Ledger(LEDGER_DIR)
|
||||||
|
company = led.get_company(slug)
|
||||||
|
if company is None:
|
||||||
|
raise HTTPException(404, "no such company")
|
||||||
|
return progress_mod.analyze(company, led.deck_records(slug) or [])
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/companies/{slug}/progress")
|
||||||
|
def company_progress(slug: str):
|
||||||
|
return _progress_analysis(os.path.basename(slug))
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/companies/{slug}/progress.md", response_class=PlainTextResponse)
|
||||||
|
def company_progress_md(slug: str):
|
||||||
|
return progress_mod.render_progress_md(_progress_analysis(os.path.basename(slug)))
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/companies/{slug}/scorecard", response_class=PlainTextResponse)
|
@app.get("/api/companies/{slug}/scorecard", response_class=PlainTextResponse)
|
||||||
def company_scorecard(slug: str):
|
def company_scorecard(slug: str):
|
||||||
slug = os.path.basename(slug)
|
slug = os.path.basename(slug)
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ import extraction
|
|||||||
import graders as gr_mod
|
import graders as gr_mod
|
||||||
import ledger as ledger_mod
|
import ledger as ledger_mod
|
||||||
import preflight
|
import preflight
|
||||||
|
import progress as progress_mod
|
||||||
import scorecard
|
import scorecard
|
||||||
import scoring
|
import scoring
|
||||||
import serving
|
import serving
|
||||||
@@ -605,14 +606,20 @@ class JobRunner:
|
|||||||
with open(os.path.join(report_deck_dir, "ADJUDICATION.md"), "w") as f:
|
with open(os.path.join(report_deck_dir, "ADJUDICATION.md"), "w") as f:
|
||||||
f.write(adjudication_md + "\n")
|
f.write(adjudication_md + "\n")
|
||||||
|
|
||||||
# Refresh the company scorecard + the /data/reports latest copy.
|
# Refresh the company scorecard + progress review + /data/reports copies.
|
||||||
sc_md = scorecard.render_scorecard(led.get_company(slug_c), led.deck_records(slug_c))
|
company_now = led.get_company(slug_c)
|
||||||
|
sc_md = scorecard.render_scorecard(company_now, all_recs)
|
||||||
sc_path = os.path.join(LEDGER_DIR, slug_c, "SCORECARD.md")
|
sc_path = os.path.join(LEDGER_DIR, slug_c, "SCORECARD.md")
|
||||||
os.makedirs(os.path.dirname(sc_path), exist_ok=True)
|
os.makedirs(os.path.dirname(sc_path), exist_ok=True)
|
||||||
with open(sc_path, "w") as f:
|
with open(sc_path, "w") as f:
|
||||||
f.write(sc_md)
|
f.write(sc_md)
|
||||||
with open(os.path.join(REPORTS_DIR, "latest-scorecard.md"), "w") as f:
|
with open(os.path.join(REPORTS_DIR, "latest-scorecard.md"), "w") as f:
|
||||||
f.write(sc_md)
|
f.write(sc_md)
|
||||||
|
prog_md = progress_mod.render_progress_md(progress_mod.analyze(company_now, all_recs))
|
||||||
|
with open(os.path.join(LEDGER_DIR, slug_c, "PROGRESS.md"), "w") as f:
|
||||||
|
f.write(prog_md)
|
||||||
|
with open(os.path.join(REPORTS_DIR, "latest-progress.md"), "w") as f:
|
||||||
|
f.write(prog_md)
|
||||||
self.log(f"[runner] reports saved to {report_deck_dir}")
|
self.log(f"[runner] reports saved to {report_deck_dir}")
|
||||||
|
|
||||||
return {"period": period, "deck_id": deck_id, "composite": _composite(record),
|
return {"period": period, "deck_id": deck_id, "composite": _composite(record),
|
||||||
|
|||||||
@@ -0,0 +1,514 @@
|
|||||||
|
"""Longitudinal progress review + deck-quality guidance — pure, stdlib only.
|
||||||
|
|
||||||
|
Where scorecard.py answers "how did the latest deck score", this module answers
|
||||||
|
two portfolio-owner questions across the whole ledger:
|
||||||
|
|
||||||
|
1. Is the company actually progressing? Composite / BDEF-category / KPI
|
||||||
|
trajectories across every graded deck, recurring vs resolved red flags.
|
||||||
|
2. Is the material good enough to judge them by? A deterministic gap engine
|
||||||
|
inspects the latest record for what the deck is NOT showing (no
|
||||||
|
profitability visibility, untargeted KPIs, no forward guidance, thin
|
||||||
|
evidence per BDEF category, silently dropped KPIs, no board asks) and
|
||||||
|
turns each gap into a concrete request for the next deck.
|
||||||
|
|
||||||
|
Everything numeric is derived from scoring records the deterministic pipeline
|
||||||
|
already produced — no model calls, so the dashboard can compute this live.
|
||||||
|
`analyze(company, records)` returns the JSON shape; `render_progress_md`
|
||||||
|
renders it as PROGRESS.md.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import decks as decks_mod
|
||||||
|
|
||||||
|
_CATEGORIES = "ABCDEFGH"
|
||||||
|
_CATEGORY_TITLES = {
|
||||||
|
"A": "Incentive Alignment & Skin in the Game",
|
||||||
|
"B": "Inversion Discipline & Margin of Safety",
|
||||||
|
"C": "Circle of Competence & Rational Learning",
|
||||||
|
"D": "Capital Allocation Quality",
|
||||||
|
"E": "Moat Durability & Competitive Reality",
|
||||||
|
"F": "Psychological & Cultural Health",
|
||||||
|
"G": "Simplicity, Clarity & Decision Velocity",
|
||||||
|
"H": "Board Value-Add & Governance Quality",
|
||||||
|
}
|
||||||
|
|
||||||
|
# What a deck that scores well on each BDEF category actually shows (bdef.md's
|
||||||
|
# "5" anchors, phrased as material to request). Used when a category's evidence
|
||||||
|
# is thin: the graders had nothing to quote, so the deck isn't showing this.
|
||||||
|
_CATEGORY_MATERIAL = {
|
||||||
|
"A": "comp structure and promotion criteria, management/founder ownership and "
|
||||||
|
"option-pool detail, and which metrics bonuses actually pay on",
|
||||||
|
"B": "a downside scenario for the plan (what breaks the thesis), cash "
|
||||||
|
"buffer/runway under that scenario, and pre-committed 'we won't do X' "
|
||||||
|
"boundaries",
|
||||||
|
"C": "explicit 'we know / we don't know' boundaries, postmortems on this "
|
||||||
|
"period's misses, and the criteria used before entering adjacencies",
|
||||||
|
"D": "expected return on each major use of cash compared against the "
|
||||||
|
"alternatives — including doing nothing or returning capital",
|
||||||
|
"E": "specific, testable moat metrics (retention/churn, switching costs, "
|
||||||
|
"pricing power, win rates) plus named competitive threats and the "
|
||||||
|
"response to each",
|
||||||
|
"F": "what went wrong this period surfaced by management first, regrettable "
|
||||||
|
"attrition, and culture signals beyond an engagement score",
|
||||||
|
"G": "a one-page summary of the 2–3 decisions that matter, with KPI "
|
||||||
|
"definitions and format kept identical to the prior deck",
|
||||||
|
"H": "explicit asks for the board, each with a recommendation and what "
|
||||||
|
"would make that recommendation wrong",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Latest-deck evidence_quality below this = the panel scored the category on
|
||||||
|
# thin air (scores regress to 3 anyway) — the deck isn't showing the material.
|
||||||
|
THIN_EVIDENCE = 0.35
|
||||||
|
# |net composite change| within this band counts as flat.
|
||||||
|
FLAT_BAND = 1.0
|
||||||
|
# Category adjusted-score moves within this band count as flat.
|
||||||
|
CAT_BAND = 0.3
|
||||||
|
|
||||||
|
|
||||||
|
def _f(x) -> float | None:
|
||||||
|
return float(x) if isinstance(x, (int, float)) and not isinstance(x, bool) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _cats(rec: dict) -> dict:
|
||||||
|
return ((rec.get("qual") or {}).get("categories")) or {}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- trajectory
|
||||||
|
def _trend(records: list[dict]) -> dict:
|
||||||
|
series = []
|
||||||
|
prev_comp = None
|
||||||
|
for r in records:
|
||||||
|
comp = _f(r.get("composite"))
|
||||||
|
entry = {
|
||||||
|
"period": r.get("period"),
|
||||||
|
"composite": comp,
|
||||||
|
"quant": _f((r.get("quant") or {}).get("score")),
|
||||||
|
"qual": _f((r.get("qual") or {}).get("score")),
|
||||||
|
"penalties": _f((r.get("penalties") or {}).get("total")),
|
||||||
|
"delta": (round(comp - prev_comp, 1)
|
||||||
|
if comp is not None and prev_comp is not None else None),
|
||||||
|
}
|
||||||
|
series.append(entry)
|
||||||
|
prev_comp = comp if comp is not None else prev_comp
|
||||||
|
scored = [s for s in series if s["composite"] is not None]
|
||||||
|
out = {"series": series, "direction": "insufficient", "net_change": None,
|
||||||
|
"best": None, "worst": None}
|
||||||
|
if not scored:
|
||||||
|
return out
|
||||||
|
out["best"] = max(scored, key=lambda s: s["composite"])
|
||||||
|
out["worst"] = min(scored, key=lambda s: s["composite"])
|
||||||
|
if len(scored) < 2:
|
||||||
|
return out
|
||||||
|
net = round(scored[-1]["composite"] - scored[0]["composite"], 1)
|
||||||
|
out["net_change"] = net
|
||||||
|
deltas = [s["delta"] for s in scored[1:] if s["delta"] is not None]
|
||||||
|
if abs(net) <= FLAT_BAND:
|
||||||
|
out["direction"] = "flat"
|
||||||
|
elif net > 0:
|
||||||
|
out["direction"] = "improving" if all(d >= -FLAT_BAND for d in deltas) else "mixed"
|
||||||
|
else:
|
||||||
|
out["direction"] = "declining" if all(d <= FLAT_BAND for d in deltas) else "mixed"
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _category_trends(records: list[dict]) -> dict:
|
||||||
|
out: dict[str, dict] = {}
|
||||||
|
for cid in _CATEGORIES:
|
||||||
|
series = []
|
||||||
|
for r in records:
|
||||||
|
c = _cats(r).get(cid) or {}
|
||||||
|
series.append({"period": r.get("period"),
|
||||||
|
"adjusted": _f(c.get("adjusted")),
|
||||||
|
"evidence_quality": _f(c.get("evidence_quality"))})
|
||||||
|
scored = [s for s in series if s["adjusted"] is not None]
|
||||||
|
if not scored:
|
||||||
|
continue
|
||||||
|
latest = scored[-1]
|
||||||
|
net = (round(latest["adjusted"] - scored[0]["adjusted"], 2)
|
||||||
|
if len(scored) >= 2 else None)
|
||||||
|
direction = ("flat" if net is None or abs(net) <= CAT_BAND
|
||||||
|
else "improving" if net > 0 else "declining")
|
||||||
|
evid = [s["evidence_quality"] for s in scored if s["evidence_quality"] is not None]
|
||||||
|
out[cid] = {
|
||||||
|
"title": _CATEGORY_TITLES[cid],
|
||||||
|
"series": series,
|
||||||
|
"latest": latest["adjusted"],
|
||||||
|
"net_change": net,
|
||||||
|
"direction": direction if len(scored) >= 2 else "insufficient",
|
||||||
|
"latest_evidence": latest["evidence_quality"],
|
||||||
|
"avg_evidence": round(sum(evid) / len(evid), 4) if evid else None,
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _kpi_trends(records: list[dict]) -> dict:
|
||||||
|
"""Per canonical KPI across records: credit series + a trajectory status."""
|
||||||
|
out: dict[str, dict] = {}
|
||||||
|
order: list[str] = []
|
||||||
|
for r in records:
|
||||||
|
for k in r.get("kpi_results") or []:
|
||||||
|
cn = (k.get("canonical_name") or k.get("name") or "").strip()
|
||||||
|
if not cn:
|
||||||
|
continue
|
||||||
|
s = out.get(cn)
|
||||||
|
if s is None:
|
||||||
|
s = out[cn] = {"name": k.get("name") or cn, "profitability": False,
|
||||||
|
"series": [], "attempts": 0, "hits": 0,
|
||||||
|
"last_credit": None, "status": "untargeted",
|
||||||
|
"last_period": None}
|
||||||
|
order.append(cn)
|
||||||
|
if k.get("name"):
|
||||||
|
s["name"] = k["name"]
|
||||||
|
s["profitability"] = bool(k.get("profitability", s["profitability"]))
|
||||||
|
credit = _f(k.get("credit"))
|
||||||
|
s["series"].append({"period": r.get("period"), "credit": credit,
|
||||||
|
"target_source": k.get("target_source")})
|
||||||
|
s["last_period"] = r.get("period")
|
||||||
|
if credit is not None:
|
||||||
|
s["attempts"] += 1
|
||||||
|
s["last_credit"] = credit
|
||||||
|
if credit >= 0.999:
|
||||||
|
s["hits"] += 1
|
||||||
|
latest_period = records[-1].get("period") if records else None
|
||||||
|
for s in out.values():
|
||||||
|
credits = [p["credit"] for p in s["series"] if p["credit"] is not None]
|
||||||
|
if s["last_period"] != latest_period:
|
||||||
|
s["status"] = "not-reported" # vanished from the latest deck
|
||||||
|
elif not credits:
|
||||||
|
s["status"] = "untargeted" # reported, but never had a target
|
||||||
|
elif credits[-1] >= 0.999:
|
||||||
|
s["status"] = ("recovered" if len(credits) >= 2 and credits[-2] < 0.999
|
||||||
|
else "on-track")
|
||||||
|
elif len(credits) >= 2 and all(c < 0.999 for c in credits[-2:]):
|
||||||
|
s["status"] = "missing-repeatedly"
|
||||||
|
elif len(credits) >= 2 and credits[-2] >= 0.999:
|
||||||
|
s["status"] = "slipped"
|
||||||
|
else:
|
||||||
|
s["status"] = "missed-latest"
|
||||||
|
return {cn: out[cn] for cn in order}
|
||||||
|
|
||||||
|
|
||||||
|
def _flag_history(records: list[dict]) -> dict:
|
||||||
|
"""Recurring / new-in-latest / resolved flags by code across records."""
|
||||||
|
seen: dict[str, dict] = {}
|
||||||
|
for r in records:
|
||||||
|
for f in (r.get("penalties") or {}).get("flags") or []:
|
||||||
|
code = f.get("code") or "flag"
|
||||||
|
s = seen.setdefault(code, {"code": code, "description": f.get("description") or "",
|
||||||
|
"severity": 0, "periods": []})
|
||||||
|
s["severity"] = max(s["severity"], int(f.get("severity") or 0))
|
||||||
|
s["description"] = f.get("description") or s["description"]
|
||||||
|
if r.get("period") not in s["periods"]:
|
||||||
|
s["periods"].append(r.get("period"))
|
||||||
|
latest_codes = {f.get("code") for f in
|
||||||
|
((records[-1].get("penalties") or {}).get("flags") or [])} if records else set()
|
||||||
|
prior_codes = set(seen) - latest_codes
|
||||||
|
recurring = sorted((s for s in seen.values() if len(s["periods"]) >= 2),
|
||||||
|
key=lambda s: (-len(s["periods"]), -s["severity"]))
|
||||||
|
new = sorted((s for s in seen.values()
|
||||||
|
if s["code"] in latest_codes and len(s["periods"]) == 1),
|
||||||
|
key=lambda s: -s["severity"])
|
||||||
|
resolved = sorted((s for s in seen.values() if s["code"] in prior_codes),
|
||||||
|
key=lambda s: -s["severity"])
|
||||||
|
return {"recurring": recurring, "new_in_latest": new, "resolved": resolved}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- gap engine
|
||||||
|
def _gaps(company: dict, records: list[dict], categories: dict,
|
||||||
|
kpis: dict, flags: dict) -> list[dict]:
|
||||||
|
"""Deck-quality findings on the LATEST record: what the material isn't
|
||||||
|
showing, each with a concrete request for the next deck."""
|
||||||
|
if not records:
|
||||||
|
return []
|
||||||
|
latest = records[-1]
|
||||||
|
out: list[dict] = []
|
||||||
|
|
||||||
|
def gap(code, severity, title, detail, ask):
|
||||||
|
out.append({"code": code, "severity": severity, "title": title,
|
||||||
|
"detail": detail, "ask": ask})
|
||||||
|
|
||||||
|
q = latest.get("quant") or {}
|
||||||
|
if (q.get("profitability") or {}).get("na"):
|
||||||
|
gap("no_profitability_kpis", 3, "No profitability visibility",
|
||||||
|
"The latest deck reports no profit, margin, or cash KPI the pipeline "
|
||||||
|
"could score — the heaviest bucket (30/60 quant points) sat empty.",
|
||||||
|
"Report gross margin, EBITDA (or net burn) and cash runway as "
|
||||||
|
"first-class KPIs every period, each with a stated target.")
|
||||||
|
|
||||||
|
untargeted = [s["name"] for s in kpis.values()
|
||||||
|
if s["status"] == "untargeted" and s["last_period"] == latest.get("period")]
|
||||||
|
if untargeted:
|
||||||
|
gap("untargeted_kpis", 2,
|
||||||
|
f"KPIs reported without targets ({len(untargeted)})",
|
||||||
|
"These KPIs appear in the deck but carry no target from any source "
|
||||||
|
f"(pinned, prior guidance, or in-deck): {', '.join(sorted(untargeted))}. "
|
||||||
|
"Untargeted KPIs earn no quant credit and can't be held to account.",
|
||||||
|
"State a target next to every KPI — or set pinned targets in "
|
||||||
|
"Configure Companies for the ones the board owns.")
|
||||||
|
|
||||||
|
# Forward guidance: did the latest deck leave targets for any FUTURE period?
|
||||||
|
latest_key = decks_mod.period_sort_key(latest.get("period"))
|
||||||
|
extracted = company.get("extracted_targets") or {}
|
||||||
|
has_forward = any(decks_mod.period_sort_key(p) > latest_key for p in extracted)
|
||||||
|
if not has_forward:
|
||||||
|
gap("no_forward_guidance", 3, "No forward guidance for next period",
|
||||||
|
"No forward targets for a future period were extracted from the "
|
||||||
|
"latest deck, so the next deck's forecast-integrity bucket will be "
|
||||||
|
"unscorable and management can't be graded against its own plan.",
|
||||||
|
"Include an explicit next-period target for each headline KPI "
|
||||||
|
"(the number, the period, and the direction).")
|
||||||
|
|
||||||
|
if len(records) >= 2 and (q.get("forecast_integrity") or {}).get("na"):
|
||||||
|
gap("forecast_chain_broken", 2, "Forecast chain broken",
|
||||||
|
"This deck's actuals could not be matched against the prior deck's "
|
||||||
|
"targets — either no prior guidance existed or the KPI names changed "
|
||||||
|
"between periods.",
|
||||||
|
"Keep KPI names identical period to period (or record aliases in "
|
||||||
|
"Configure Companies so renamed KPIs still chain).")
|
||||||
|
|
||||||
|
dropped = [s["name"] for s in kpis.values() if s["status"] == "not-reported"]
|
||||||
|
if dropped:
|
||||||
|
gap("dropped_kpis", 3,
|
||||||
|
f"Previously reported KPIs vanished ({len(dropped)})",
|
||||||
|
f"No longer reported as of {latest.get('period')}: "
|
||||||
|
f"{', '.join(sorted(dropped))}. Silently dropped KPIs are a classic "
|
||||||
|
"way bad news leaves a deck.",
|
||||||
|
"Restore these KPIs — or retire them explicitly with one line on why.")
|
||||||
|
|
||||||
|
thin = [(cid, c) for cid, c in categories.items()
|
||||||
|
if c.get("latest") is not None
|
||||||
|
and (c.get("latest_evidence") or 0.0) < THIN_EVIDENCE]
|
||||||
|
for cid, c in sorted(thin):
|
||||||
|
gap(f"thin_evidence_{cid}", 2,
|
||||||
|
f"{cid}. {c['title']} — scored on thin evidence",
|
||||||
|
f"The panel found almost nothing to quote for this category "
|
||||||
|
f"(evidence quality {c.get('latest_evidence') or 0.0:.0%}; scores "
|
||||||
|
"regress to neutral without support). The deck isn't showing this "
|
||||||
|
"dimension at all.",
|
||||||
|
f"Add material on {_CATEGORY_MATERIAL[cid]}.")
|
||||||
|
|
||||||
|
asks = ((latest.get("narrative") or {}).get("asks")) or []
|
||||||
|
if not asks:
|
||||||
|
gap("no_board_asks", 2, "No asks for the board",
|
||||||
|
"The deck asks the board for nothing — the board is positioned to "
|
||||||
|
"preside rather than govern (BDEF category H).",
|
||||||
|
"End the deck with the 2–3 decisions that matter, each with a "
|
||||||
|
"recommendation and what would make it wrong.")
|
||||||
|
|
||||||
|
# Recurring flags only demand action while still open in the latest deck —
|
||||||
|
# a flag that recurred historically but is clear now shows under "resolved".
|
||||||
|
latest_codes = {f.get("code") for f in
|
||||||
|
(latest.get("penalties") or {}).get("flags") or []}
|
||||||
|
for f in flags.get("recurring") or []:
|
||||||
|
if f["code"] in latest_codes:
|
||||||
|
gap(f"recurring_{f['code']}", min(3, f["severity"]),
|
||||||
|
f"Recurring flag: {f['code']}",
|
||||||
|
f"Raised in {len(f['periods'])} decks ({', '.join(str(p) for p in f['periods'])}) "
|
||||||
|
f"and still open: {f['description']}",
|
||||||
|
f"Address `{f['code']}` head-on in the next deck — acknowledge it "
|
||||||
|
"and show the fix, so the penalty stops repeating.")
|
||||||
|
|
||||||
|
out.sort(key=lambda g: (-g["severity"], g["code"]))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- entry point
|
||||||
|
def analyze(company: dict, records: list[dict]) -> dict:
|
||||||
|
"""Full progress analysis for one company over its period-sorted records."""
|
||||||
|
company = company or {}
|
||||||
|
records = records or []
|
||||||
|
categories = _category_trends(records)
|
||||||
|
kpis = _kpi_trends(records)
|
||||||
|
flags = _flag_history(records)
|
||||||
|
return {
|
||||||
|
"schema_version": 1,
|
||||||
|
"slug": company.get("slug"),
|
||||||
|
"name": company.get("name") or company.get("slug"),
|
||||||
|
"deck_count": len(records),
|
||||||
|
"trend": _trend(records),
|
||||||
|
"categories": categories,
|
||||||
|
"kpis": kpis,
|
||||||
|
"flags": flags,
|
||||||
|
"gaps": _gaps(company, records, categories, kpis, flags),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- markdown
|
||||||
|
def _fmt(x, digits: int = 1) -> str:
|
||||||
|
return "—" if x is None else (f"{x:.{digits}f}" if isinstance(x, float) else str(x))
|
||||||
|
|
||||||
|
|
||||||
|
def _signed(x, digits: int = 1) -> str:
|
||||||
|
if x is None:
|
||||||
|
return "—"
|
||||||
|
return f"{'+' if x > 0 else ''}{x:.{digits}f}"
|
||||||
|
|
||||||
|
|
||||||
|
_STATUS_LABEL = {
|
||||||
|
"on-track": "on track", "recovered": "recovered", "slipped": "slipped",
|
||||||
|
"missed-latest": "missed latest", "missing-repeatedly": "missing repeatedly",
|
||||||
|
"untargeted": "no target", "not-reported": "NOT REPORTED",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def render_progress_md(analysis: dict) -> str:
|
||||||
|
"""PROGRESS.md — the analyze() result as a readable review."""
|
||||||
|
a = analysis
|
||||||
|
lines = [f"# Progress review — {a.get('name') or '?'}", ""]
|
||||||
|
n = a.get("deck_count") or 0
|
||||||
|
if not n:
|
||||||
|
lines.append("No graded decks yet.")
|
||||||
|
return "\n".join(lines) + "\n"
|
||||||
|
|
||||||
|
t = a.get("trend") or {}
|
||||||
|
series = t.get("series") or []
|
||||||
|
scored = [s for s in series if s.get("composite") is not None]
|
||||||
|
|
||||||
|
# --- verdict
|
||||||
|
lines.append("## Verdict")
|
||||||
|
lines.append("")
|
||||||
|
if len(scored) >= 2:
|
||||||
|
first, last = scored[0], scored[-1]
|
||||||
|
lines.append(
|
||||||
|
f"Trajectory over {n} graded decks: **{t.get('direction')}** — composite "
|
||||||
|
f"{_fmt(first['composite'])} ({first.get('period')}) → "
|
||||||
|
f"{_fmt(last['composite'])} ({last.get('period')}), "
|
||||||
|
f"net {_signed(t.get('net_change'))}. "
|
||||||
|
f"Best {_fmt((t.get('best') or {}).get('composite'))} "
|
||||||
|
f"({(t.get('best') or {}).get('period')}); "
|
||||||
|
f"worst {_fmt((t.get('worst') or {}).get('composite'))} "
|
||||||
|
f"({(t.get('worst') or {}).get('period')}).")
|
||||||
|
else:
|
||||||
|
lines.append(f"Only one graded deck ({scored[0].get('period') if scored else '?'}) — "
|
||||||
|
"trajectory starts with the next deck.")
|
||||||
|
gaps = a.get("gaps") or []
|
||||||
|
if gaps:
|
||||||
|
lines.append("")
|
||||||
|
lines.append(f"Deck quality: **{len(gaps)} gap(s)** in what the material shows "
|
||||||
|
"— see “What the materials aren't showing” below.")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# --- composite table
|
||||||
|
lines.append("## Composite progress")
|
||||||
|
lines.append("")
|
||||||
|
lines.append("| Period | Composite | Δ | Quant | Qual | Penalties |")
|
||||||
|
lines.append("|---|---|---|---|---|---|")
|
||||||
|
for s in series:
|
||||||
|
lines.append(f"| {s.get('period') or '?'} | {_fmt(s.get('composite'))} "
|
||||||
|
f"| {_signed(s.get('delta'))} | {_fmt(s.get('quant'))} "
|
||||||
|
f"| {_fmt(s.get('qual'))} | −{_fmt(s.get('penalties'))} |")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# --- what's moving
|
||||||
|
cats = a.get("categories") or {}
|
||||||
|
kpis = a.get("kpis") or {}
|
||||||
|
up = [(cid, c) for cid, c in cats.items() if c.get("direction") == "improving"]
|
||||||
|
down = [(cid, c) for cid, c in cats.items() if c.get("direction") == "declining"]
|
||||||
|
kpi_good = [s for s in kpis.values() if s["status"] in ("on-track", "recovered")]
|
||||||
|
kpi_bad = [s for s in kpis.values()
|
||||||
|
if s["status"] in ("slipped", "missing-repeatedly", "missed-latest",
|
||||||
|
"not-reported")]
|
||||||
|
lines.append("## What's moving")
|
||||||
|
lines.append("")
|
||||||
|
lines.append("### Improving")
|
||||||
|
lines.append("")
|
||||||
|
for cid, c in sorted(up, key=lambda x: -(x[1].get("net_change") or 0)):
|
||||||
|
lines.append(f"- **{cid}. {c['title']}** — {_fmt(c.get('latest'), 2)} "
|
||||||
|
f"({_signed(c.get('net_change'), 2)} since first deck)")
|
||||||
|
for s in sorted(kpi_good, key=lambda s: s["name"].lower()):
|
||||||
|
label = _STATUS_LABEL[s["status"]]
|
||||||
|
lines.append(f"- KPI **{s['name']}** — {label} "
|
||||||
|
f"({s['hits']}/{s['attempts']} targets hit)")
|
||||||
|
if not up and not kpi_good:
|
||||||
|
lines.append("- Nothing improving yet.")
|
||||||
|
lines.append("")
|
||||||
|
lines.append("### Declining / watch")
|
||||||
|
lines.append("")
|
||||||
|
for cid, c in sorted(down, key=lambda x: (x[1].get("net_change") or 0)):
|
||||||
|
lines.append(f"- **{cid}. {c['title']}** — {_fmt(c.get('latest'), 2)} "
|
||||||
|
f"({_signed(c.get('net_change'), 2)} since first deck)")
|
||||||
|
for s in sorted(kpi_bad, key=lambda s: s["name"].lower()):
|
||||||
|
lines.append(f"- KPI **{s['name']}** — {_STATUS_LABEL[s['status']]}"
|
||||||
|
+ (f" (last credit {_fmt(s['last_credit'], 2)})"
|
||||||
|
if s["last_credit"] is not None else ""))
|
||||||
|
if not down and not kpi_bad:
|
||||||
|
lines.append("- Nothing declining.")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# --- KPI trajectory
|
||||||
|
if kpis:
|
||||||
|
lines.append("## KPI trajectory")
|
||||||
|
lines.append("")
|
||||||
|
lines.append("| KPI | Profit | Trend (oldest → latest) | Hits | Status |")
|
||||||
|
lines.append("|---|---|---|---|---|")
|
||||||
|
ordered = sorted(kpis.values(),
|
||||||
|
key=lambda s: (not s["profitability"], s["name"].lower()))
|
||||||
|
for s in ordered:
|
||||||
|
marks = []
|
||||||
|
for p in s["series"]:
|
||||||
|
c = p.get("credit")
|
||||||
|
marks.append("·" if c is None else "✓" if c >= 0.999 else "✗")
|
||||||
|
lines.append(f"| {s['name']} | {'yes' if s['profitability'] else ''} "
|
||||||
|
f"| {' '.join(marks)} | {s['hits']}/{s['attempts']} "
|
||||||
|
f"| {_STATUS_LABEL[s['status']]} |")
|
||||||
|
lines.append("")
|
||||||
|
lines.append("✓ target hit · ✗ missed · `·` no target that period")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# --- flags
|
||||||
|
fl = a.get("flags") or {}
|
||||||
|
lines.append("## Flag history")
|
||||||
|
lines.append("")
|
||||||
|
rec = fl.get("recurring") or []
|
||||||
|
if rec:
|
||||||
|
lines.append("| Recurring flag | Severity | Seen in |")
|
||||||
|
lines.append("|---|---|---|")
|
||||||
|
for f in rec:
|
||||||
|
lines.append(f"| `{f['code']}` | {f['severity']} "
|
||||||
|
f"| {', '.join(str(p) for p in f['periods'])} |")
|
||||||
|
lines.append("")
|
||||||
|
new = fl.get("new_in_latest") or []
|
||||||
|
if new:
|
||||||
|
lines.append("New in the latest deck: " +
|
||||||
|
", ".join(f"`{f['code']}`" for f in new))
|
||||||
|
lines.append("")
|
||||||
|
res = fl.get("resolved") or []
|
||||||
|
if res:
|
||||||
|
lines.append("Resolved (raised earlier, clear in the latest deck): " +
|
||||||
|
", ".join(f"`{f['code']}`" for f in res))
|
||||||
|
lines.append("")
|
||||||
|
if not rec and not new and not res:
|
||||||
|
lines.append("No red flags raised on any graded deck.")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# --- deck quality gaps
|
||||||
|
lines.append("## What the materials aren't showing")
|
||||||
|
lines.append("")
|
||||||
|
if gaps:
|
||||||
|
for g in gaps:
|
||||||
|
lines.append(f"### {g['title']}")
|
||||||
|
lines.append("")
|
||||||
|
lines.append(g["detail"])
|
||||||
|
lines.append("")
|
||||||
|
else:
|
||||||
|
lines.append("No material gaps detected — the deck gives the pipeline "
|
||||||
|
"everything it needs to grade this company.")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# --- the ask list
|
||||||
|
if gaps:
|
||||||
|
lines.append("## Requests for the next deck")
|
||||||
|
lines.append("")
|
||||||
|
seen: set[str] = set()
|
||||||
|
i = 0
|
||||||
|
for g in gaps:
|
||||||
|
ask = g["ask"].strip()
|
||||||
|
if ask.lower() in seen:
|
||||||
|
continue
|
||||||
|
seen.add(ask.lower())
|
||||||
|
i += 1
|
||||||
|
lines.append(f"{i}. {ask}")
|
||||||
|
lines.append("")
|
||||||
|
lines.append("*Paste this list into the board follow-up — each item maps "
|
||||||
|
"to a gap above.*")
|
||||||
|
return "\n".join(lines).rstrip() + "\n"
|
||||||
@@ -149,6 +149,7 @@
|
|||||||
<div id="coDecks"><div class="muted">—</div></div>
|
<div id="coDecks"><div class="muted">—</div></div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<button onclick="toggleScorecard()" id="scBtn">View SCORECARD.md</button>
|
<button onclick="toggleScorecard()" id="scBtn">View SCORECARD.md</button>
|
||||||
|
<button onclick="toggleProgress()" id="prBtn">View progress review</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="coViewer" style="margin-top:10px"></div>
|
<div id="coViewer" style="margin-top:10px"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -419,11 +420,15 @@ async function openCompany(slug,silent){
|
|||||||
}catch(e){ if(!silent) alert('load company failed: '+(e.message||e)); }
|
}catch(e){ if(!silent) alert('load company failed: '+(e.message||e)); }
|
||||||
}
|
}
|
||||||
function closeCompany(){
|
function closeCompany(){
|
||||||
currentSlug=null; scorecardOpen=false;
|
currentSlug=null; resetViewerToggles();
|
||||||
document.getElementById('coViewer').innerHTML='';
|
document.getElementById('coViewer').innerHTML='';
|
||||||
document.getElementById('scBtn').textContent='View SCORECARD.md';
|
|
||||||
document.getElementById('companyCard').style.display='none';
|
document.getElementById('companyCard').style.display='none';
|
||||||
}
|
}
|
||||||
|
function resetViewerToggles(){
|
||||||
|
scorecardOpen=false; progressOpen=false;
|
||||||
|
document.getElementById('scBtn').textContent='View SCORECARD.md';
|
||||||
|
document.getElementById('prBtn').textContent='View progress review';
|
||||||
|
}
|
||||||
|
|
||||||
let currentRecs=[];
|
let currentRecs=[];
|
||||||
function renderDetail(d,changed){
|
function renderDetail(d,changed){
|
||||||
@@ -469,9 +474,8 @@ function renderDetail(d,changed){
|
|||||||
// Only reset the viewer when switching companies — periodic re-renders must
|
// Only reset the viewer when switching companies — periodic re-renders must
|
||||||
// not close whatever report the user has open.
|
// not close whatever report the user has open.
|
||||||
if(changed){
|
if(changed){
|
||||||
scorecardOpen=false;
|
resetViewerToggles();
|
||||||
document.getElementById('coViewer').innerHTML='';
|
document.getElementById('coViewer').innerHTML='';
|
||||||
document.getElementById('scBtn').textContent='View SCORECARD.md';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async function deleteCompany(){
|
async function deleteCompany(){
|
||||||
@@ -525,11 +529,11 @@ async function viewDeckJson(deckId){
|
|||||||
deckBase(deckId), `${currentSlug}-${deckId}.json`);
|
deckBase(deckId), `${currentSlug}-${deckId}.json`);
|
||||||
}catch(e){ alert('load record failed: '+(e.message||e)); }
|
}catch(e){ alert('load record failed: '+(e.message||e)); }
|
||||||
}
|
}
|
||||||
let scorecardOpen=false;
|
let scorecardOpen=false, progressOpen=false;
|
||||||
async function toggleScorecard(){
|
async function toggleScorecard(){
|
||||||
if(!currentSlug) return;
|
if(!currentSlug) return;
|
||||||
const v=document.getElementById('coViewer'), btn=document.getElementById('scBtn');
|
const v=document.getElementById('coViewer'), btn=document.getElementById('scBtn');
|
||||||
if(scorecardOpen){ v.innerHTML=''; scorecardOpen=false; btn.textContent='View SCORECARD.md'; return; }
|
if(scorecardOpen){ v.innerHTML=''; resetViewerToggles(); return; }
|
||||||
try{
|
try{
|
||||||
const url='/api/companies/'+encodeURIComponent(currentSlug)+'/scorecard';
|
const url='/api/companies/'+encodeURIComponent(currentSlug)+'/scorecard';
|
||||||
const r=await fetch(url);
|
const r=await fetch(url);
|
||||||
@@ -539,9 +543,21 @@ async function toggleScorecard(){
|
|||||||
scorecardOpen=true; btn.textContent='Hide SCORECARD.md';
|
scorecardOpen=true; btn.textContent='Hide SCORECARD.md';
|
||||||
}catch(e){ alert(e); }
|
}catch(e){ alert(e); }
|
||||||
}
|
}
|
||||||
|
async function toggleProgress(){
|
||||||
|
if(!currentSlug) return;
|
||||||
|
const v=document.getElementById('coViewer'), btn=document.getElementById('prBtn');
|
||||||
|
if(progressOpen){ v.innerHTML=''; resetViewerToggles(); return; }
|
||||||
|
try{
|
||||||
|
const url='/api/companies/'+encodeURIComponent(currentSlug)+'/progress.md';
|
||||||
|
const r=await fetch(url);
|
||||||
|
const t=await r.text();
|
||||||
|
setViewer('Progress review', r.ok? mdToHtml(t) : `<pre class="tall">${esc('error: '+t)}</pre>`,
|
||||||
|
url, `${currentSlug}-PROGRESS.md`);
|
||||||
|
progressOpen=true; btn.textContent='Hide progress review';
|
||||||
|
}catch(e){ alert(e); }
|
||||||
|
}
|
||||||
function setViewer(title,html,dlHref,dlName){
|
function setViewer(title,html,dlHref,dlName){
|
||||||
scorecardOpen=false;
|
resetViewerToggles();
|
||||||
document.getElementById('scBtn').textContent='View SCORECARD.md';
|
|
||||||
const dl=dlHref?`<a class="mini" style="margin-left:auto" href="${dlHref}" `+
|
const dl=dlHref?`<a class="mini" style="margin-left:auto" href="${dlHref}" `+
|
||||||
`download="${esc(dlName||'report.txt')}" title="Download">⬇ download</a>`:'';
|
`download="${esc(dlName||'report.txt')}" title="Download">⬇ download</a>`:'';
|
||||||
document.getElementById('coViewer').innerHTML=
|
document.getElementById('coViewer').innerHTML=
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
"""Tests for progress.py: trajectory, KPI/category trends, and the gap engine."""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
import progress
|
||||||
|
|
||||||
|
|
||||||
|
def _cat(adjusted, evidence=0.8):
|
||||||
|
return {"adjusted": adjusted, "evidence_quality": evidence}
|
||||||
|
|
||||||
|
|
||||||
|
def _kpi(canonical, credit, profitability=False, source="pinned"):
|
||||||
|
return {"canonical_name": canonical, "name": canonical, "credit": credit,
|
||||||
|
"profitability": profitability,
|
||||||
|
"target_source": None if credit is None else source}
|
||||||
|
|
||||||
|
|
||||||
|
def _record(period, composite, cats=None, kpis=None, flags=None,
|
||||||
|
prof_na=False, forecast_na=False, asks=None):
|
||||||
|
return {
|
||||||
|
"period": period, "composite": composite, "deck_id": period.lower(),
|
||||||
|
"quant": {"score": composite * 0.6,
|
||||||
|
"profitability": {"na": prof_na},
|
||||||
|
"other": {"na": False},
|
||||||
|
"forecast_integrity": {"na": forecast_na}},
|
||||||
|
"qual": {"score": composite * 0.4, "categories": cats or {}},
|
||||||
|
"penalties": {"total": 0.0, "flags": flags or []},
|
||||||
|
"kpi_results": kpis or [],
|
||||||
|
"narrative": {"asks": asks if asks is not None else ["approve budget"]},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _company(extracted_targets=None):
|
||||||
|
return {"slug": "acme", "name": "Acme", "pinned_targets": [],
|
||||||
|
"extracted_targets": extracted_targets or {}}
|
||||||
|
|
||||||
|
|
||||||
|
class TestTrend(unittest.TestCase):
|
||||||
|
def test_improving(self):
|
||||||
|
recs = [_record("2025-Q4", 40.0), _record("2026-Q1", 42.0),
|
||||||
|
_record("2026-Q2", 45.0)]
|
||||||
|
a = progress.analyze(_company(), recs)
|
||||||
|
self.assertEqual(a["trend"]["direction"], "improving")
|
||||||
|
self.assertAlmostEqual(a["trend"]["net_change"], 5.0)
|
||||||
|
self.assertEqual(a["trend"]["best"]["period"], "2026-Q2")
|
||||||
|
self.assertEqual(a["trend"]["series"][1]["delta"], 2.0)
|
||||||
|
|
||||||
|
def test_flat_and_declining(self):
|
||||||
|
flat = progress.analyze(_company(), [_record("2026-Q1", 50.0),
|
||||||
|
_record("2026-Q2", 50.5)])
|
||||||
|
self.assertEqual(flat["trend"]["direction"], "flat")
|
||||||
|
down = progress.analyze(_company(), [_record("2026-Q1", 50.0),
|
||||||
|
_record("2026-Q2", 44.0)])
|
||||||
|
self.assertEqual(down["trend"]["direction"], "declining")
|
||||||
|
|
||||||
|
def test_single_deck_insufficient(self):
|
||||||
|
a = progress.analyze(_company(), [_record("2026-Q1", 50.0)])
|
||||||
|
self.assertEqual(a["trend"]["direction"], "insufficient")
|
||||||
|
|
||||||
|
|
||||||
|
class TestCategoryAndKpiTrends(unittest.TestCase):
|
||||||
|
def test_category_direction(self):
|
||||||
|
recs = [_record("2026-Q1", 50, cats={"A": _cat(2.5), "B": _cat(4.0)}),
|
||||||
|
_record("2026-Q2", 51, cats={"A": _cat(3.5), "B": _cat(3.0)})]
|
||||||
|
a = progress.analyze(_company(), recs)
|
||||||
|
self.assertEqual(a["categories"]["A"]["direction"], "improving")
|
||||||
|
self.assertEqual(a["categories"]["B"]["direction"], "declining")
|
||||||
|
self.assertAlmostEqual(a["categories"]["A"]["net_change"], 1.0)
|
||||||
|
|
||||||
|
def test_kpi_statuses(self):
|
||||||
|
recs = [
|
||||||
|
_record("2026-Q1", 50, kpis=[_kpi("arr", 1.0), _kpi("churn", 1.0),
|
||||||
|
_kpi("nps", None)]),
|
||||||
|
_record("2026-Q2", 51, kpis=[_kpi("arr", 0.4), _kpi("nps", None)]),
|
||||||
|
]
|
||||||
|
a = progress.analyze(_company(), recs)
|
||||||
|
self.assertEqual(a["kpis"]["arr"]["status"], "slipped")
|
||||||
|
self.assertEqual(a["kpis"]["churn"]["status"], "not-reported")
|
||||||
|
self.assertEqual(a["kpis"]["nps"]["status"], "untargeted")
|
||||||
|
|
||||||
|
def test_kpi_recovered(self):
|
||||||
|
recs = [_record("2026-Q1", 50, kpis=[_kpi("arr", 0.2)]),
|
||||||
|
_record("2026-Q2", 51, kpis=[_kpi("arr", 1.0)])]
|
||||||
|
a = progress.analyze(_company(), recs)
|
||||||
|
self.assertEqual(a["kpis"]["arr"]["status"], "recovered")
|
||||||
|
|
||||||
|
|
||||||
|
class TestFlagHistory(unittest.TestCase):
|
||||||
|
def test_recurring_new_resolved(self):
|
||||||
|
f = lambda code: {"code": code, "description": code, "severity": 2,
|
||||||
|
"points": 2.0, "sources": ["extractor"]}
|
||||||
|
recs = [_record("2026-Q1", 50, flags=[f("adjusted_metrics"), f("related_party")]),
|
||||||
|
_record("2026-Q2", 51, flags=[f("adjusted_metrics"), f("governance_gap")])]
|
||||||
|
a = progress.analyze(_company(), recs)
|
||||||
|
self.assertEqual([x["code"] for x in a["flags"]["recurring"]], ["adjusted_metrics"])
|
||||||
|
self.assertEqual([x["code"] for x in a["flags"]["new_in_latest"]], ["governance_gap"])
|
||||||
|
self.assertEqual([x["code"] for x in a["flags"]["resolved"]], ["related_party"])
|
||||||
|
|
||||||
|
|
||||||
|
class TestGapEngine(unittest.TestCase):
|
||||||
|
def codes(self, a):
|
||||||
|
return {g["code"] for g in a["gaps"]}
|
||||||
|
|
||||||
|
def test_no_profitability_gap(self):
|
||||||
|
a = progress.analyze(_company(), [_record("2026-Q2", 50, prof_na=True)])
|
||||||
|
self.assertIn("no_profitability_kpis", self.codes(a))
|
||||||
|
|
||||||
|
def test_untargeted_kpis_gap(self):
|
||||||
|
a = progress.analyze(_company(), [_record("2026-Q2", 50,
|
||||||
|
kpis=[_kpi("nps", None)])])
|
||||||
|
self.assertIn("untargeted_kpis", self.codes(a))
|
||||||
|
|
||||||
|
def test_forward_guidance(self):
|
||||||
|
recs = [_record("2026-Q2", 50)]
|
||||||
|
bare = progress.analyze(_company(), recs)
|
||||||
|
self.assertIn("no_forward_guidance", self.codes(bare))
|
||||||
|
guided = progress.analyze(
|
||||||
|
_company(extracted_targets={"2026-Q3": {"from_deck": "2026-Q2",
|
||||||
|
"targets": [{}]}}), recs)
|
||||||
|
self.assertNotIn("no_forward_guidance", self.codes(guided))
|
||||||
|
|
||||||
|
def test_forecast_chain_broken_needs_history(self):
|
||||||
|
one = progress.analyze(_company(), [_record("2026-Q2", 50, forecast_na=True)])
|
||||||
|
self.assertNotIn("forecast_chain_broken", self.codes(one))
|
||||||
|
two = progress.analyze(_company(), [_record("2026-Q1", 50),
|
||||||
|
_record("2026-Q2", 50, forecast_na=True)])
|
||||||
|
self.assertIn("forecast_chain_broken", self.codes(two))
|
||||||
|
|
||||||
|
def test_dropped_kpis_gap(self):
|
||||||
|
recs = [_record("2026-Q1", 50, kpis=[_kpi("churn", 1.0)]),
|
||||||
|
_record("2026-Q2", 51, kpis=[])]
|
||||||
|
a = progress.analyze(_company(), recs)
|
||||||
|
self.assertIn("dropped_kpis", self.codes(a))
|
||||||
|
|
||||||
|
def test_thin_evidence_gap_names_material(self):
|
||||||
|
a = progress.analyze(_company(),
|
||||||
|
[_record("2026-Q2", 50, cats={"E": _cat(3.0, 0.05)})])
|
||||||
|
gap = next(g for g in a["gaps"] if g["code"] == "thin_evidence_E")
|
||||||
|
self.assertIn("retention/churn", gap["ask"])
|
||||||
|
|
||||||
|
def test_no_board_asks_gap(self):
|
||||||
|
a = progress.analyze(_company(), [_record("2026-Q2", 50, asks=[])])
|
||||||
|
self.assertIn("no_board_asks", self.codes(a))
|
||||||
|
b = progress.analyze(_company(), [_record("2026-Q2", 50)])
|
||||||
|
self.assertNotIn("no_board_asks", self.codes(b))
|
||||||
|
|
||||||
|
def test_recurring_flag_gap(self):
|
||||||
|
f = {"code": "cash_runway_silence", "description": "no runway shown",
|
||||||
|
"severity": 3, "points": 3.0, "sources": ["extractor"]}
|
||||||
|
recs = [_record("2026-Q1", 50, flags=[f]), _record("2026-Q2", 51, flags=[f])]
|
||||||
|
a = progress.analyze(_company(), recs)
|
||||||
|
self.assertIn("recurring_cash_runway_silence", self.codes(a))
|
||||||
|
|
||||||
|
def test_recurring_flag_resolved_in_latest_no_gap(self):
|
||||||
|
f = {"code": "cash_runway_silence", "description": "no runway shown",
|
||||||
|
"severity": 3, "points": 3.0, "sources": ["extractor"]}
|
||||||
|
recs = [_record("2026-Q1", 50, flags=[f]), _record("2026-Q2", 51, flags=[f]),
|
||||||
|
_record("2026-Q3", 52, flags=[])]
|
||||||
|
a = progress.analyze(_company(), recs)
|
||||||
|
self.assertNotIn("recurring_cash_runway_silence", self.codes(a))
|
||||||
|
self.assertIn("cash_runway_silence",
|
||||||
|
[x["code"] for x in a["flags"]["resolved"]])
|
||||||
|
|
||||||
|
|
||||||
|
class TestRender(unittest.TestCase):
|
||||||
|
def test_render_full(self):
|
||||||
|
recs = [
|
||||||
|
_record("2026-Q1", 42.0, cats={"A": _cat(2.5), "E": _cat(3.0, 0.1)},
|
||||||
|
kpis=[_kpi("arr", 1.0, profitability=True), _kpi("churn", 0.5)]),
|
||||||
|
_record("2026-Q2", 45.0, cats={"A": _cat(3.4), "E": _cat(3.0, 0.1)},
|
||||||
|
kpis=[_kpi("arr", 1.0, profitability=True), _kpi("churn", 0.3)]),
|
||||||
|
]
|
||||||
|
md = progress.render_progress_md(progress.analyze(_company(), recs))
|
||||||
|
for section in ("## Verdict", "## Composite progress", "## What's moving",
|
||||||
|
"## KPI trajectory", "## Flag history",
|
||||||
|
"## What the materials aren't showing",
|
||||||
|
"## Requests for the next deck"):
|
||||||
|
self.assertIn(section, md)
|
||||||
|
self.assertIn("improving", md)
|
||||||
|
self.assertIn("✓ ✓", md) # arr hit both periods
|
||||||
|
self.assertIn("missing repeatedly", md) # churn missed twice
|
||||||
|
|
||||||
|
def test_render_empty(self):
|
||||||
|
md = progress.render_progress_md(progress.analyze(_company(), []))
|
||||||
|
self.assertIn("No graded decks yet.", md)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -8,9 +8,10 @@ import { v_0_1_5 } from './v_0_1_5'
|
|||||||
import { v_0_1_6 } from './v_0_1_6'
|
import { v_0_1_6 } from './v_0_1_6'
|
||||||
import { v_0_1_7 } from './v_0_1_7'
|
import { v_0_1_7 } from './v_0_1_7'
|
||||||
import { v_0_1_8 } from './v_0_1_8'
|
import { v_0_1_8 } from './v_0_1_8'
|
||||||
|
import { v_0_1_9 } from './v_0_1_9'
|
||||||
|
|
||||||
/** The current version MUST be the first argument (`current`). */
|
/** The current version MUST be the first argument (`current`). */
|
||||||
export const versions = VersionGraph.of({
|
export const versions = VersionGraph.of({
|
||||||
current: v_0_1_8,
|
current: v_0_1_9,
|
||||||
other: [v_0_1_7, v_0_1_6, v_0_1_5, v_0_1_4, v_0_1_3, v_0_1_2, v_0_1_1, v_0_1_0],
|
other: [v_0_1_8, v_0_1_7, v_0_1_6, v_0_1_5, v_0_1_4, v_0_1_3, v_0_1_2, v_0_1_1, v_0_1_0],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { VersionInfo } from '@start9labs/start-sdk'
|
||||||
|
|
||||||
|
/** Progress reviews + deck-quality guidance. ExVer form `<upstream>:<downstream>`. */
|
||||||
|
export const v_0_1_9 = VersionInfo.of({
|
||||||
|
version: '0.1.9:0',
|
||||||
|
releaseNotes:
|
||||||
|
'Per-company progress reviews: a new PROGRESS.md (and "View progress ' +
|
||||||
|
'review" on the dashboard company card) reads the whole graded ledger and ' +
|
||||||
|
'reports the trajectory — composite trend with per-deck deltas, which BDEF ' +
|
||||||
|
'categories and KPIs are improving or declining, and which red flags are ' +
|
||||||
|
'recurring, new, or resolved. It also grades the material itself: a ' +
|
||||||
|
'deterministic gap engine spots what the decks are NOT showing (no ' +
|
||||||
|
'profitability visibility, KPIs without targets, no forward guidance, a ' +
|
||||||
|
'broken forecast chain, silently dropped KPIs, BDEF categories scored on ' +
|
||||||
|
'thin evidence, no board asks) and turns every gap into a concrete, ' +
|
||||||
|
'paste-ready request list for the next deck. Served live from the ledger ' +
|
||||||
|
'at /api/companies/{slug}/progress(.md) — no GPU needed — and written to ' +
|
||||||
|
'/data/ledger/<company>/PROGRESS.md after each grading run.',
|
||||||
|
migrations: {},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user