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:
Jonathan Kirkwood
2026-07-31 20:50:41 -05:00
co-authored by Claude Fable 5
parent 506e6c79bd
commit 9b9c7e58c1
8 changed files with 792 additions and 14 deletions
+10 -2
View File
@@ -120,7 +120,7 @@ Canonical repo: `https://gitea.ten31.ai/Ten31AI/boardroom-map`.
## 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 /
girdley-operator / buffett-owner). First full grading run completed
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
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
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
reloads from disk (~6 min) between decks even when the model set is unchanged.
+20
View File
@@ -21,6 +21,7 @@ import decks
import graders as grader_mod
import jobs
import ledger as ledger_mod
import progress as progress_mod
import serving
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}
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)
def company_scorecard(slug: str):
slug = os.path.basename(slug)
+9 -2
View File
@@ -51,6 +51,7 @@ import extraction
import graders as gr_mod
import ledger as ledger_mod
import preflight
import progress as progress_mod
import scorecard
import scoring
import serving
@@ -605,14 +606,20 @@ class JobRunner:
with open(os.path.join(report_deck_dir, "ADJUDICATION.md"), "w") as f:
f.write(adjudication_md + "\n")
# Refresh the company scorecard + the /data/reports latest copy.
sc_md = scorecard.render_scorecard(led.get_company(slug_c), led.deck_records(slug_c))
# Refresh the company scorecard + progress review + /data/reports copies.
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")
os.makedirs(os.path.dirname(sc_path), exist_ok=True)
with open(sc_path, "w") as f:
f.write(sc_md)
with open(os.path.join(REPORTS_DIR, "latest-scorecard.md"), "w") as f:
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}")
return {"period": period, "deck_id": deck_id, "composite": _composite(record),
+514
View File
@@ -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 23 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 23 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"
+24 -8
View File
@@ -149,6 +149,7 @@
<div id="coDecks"><div class="muted"></div></div>
<div class="row">
<button onclick="toggleScorecard()" id="scBtn">View SCORECARD.md</button>
<button onclick="toggleProgress()" id="prBtn">View progress review</button>
</div>
<div id="coViewer" style="margin-top:10px"></div>
</div>
@@ -419,11 +420,15 @@ async function openCompany(slug,silent){
}catch(e){ if(!silent) alert('load company failed: '+(e.message||e)); }
}
function closeCompany(){
currentSlug=null; scorecardOpen=false;
currentSlug=null; resetViewerToggles();
document.getElementById('coViewer').innerHTML='';
document.getElementById('scBtn').textContent='View SCORECARD.md';
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=[];
function renderDetail(d,changed){
@@ -469,9 +474,8 @@ function renderDetail(d,changed){
// Only reset the viewer when switching companies — periodic re-renders must
// not close whatever report the user has open.
if(changed){
scorecardOpen=false;
resetViewerToggles();
document.getElementById('coViewer').innerHTML='';
document.getElementById('scBtn').textContent='View SCORECARD.md';
}
}
async function deleteCompany(){
@@ -525,11 +529,11 @@ async function viewDeckJson(deckId){
deckBase(deckId), `${currentSlug}-${deckId}.json`);
}catch(e){ alert('load record failed: '+(e.message||e)); }
}
let scorecardOpen=false;
let scorecardOpen=false, progressOpen=false;
async function toggleScorecard(){
if(!currentSlug) return;
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{
const url='/api/companies/'+encodeURIComponent(currentSlug)+'/scorecard';
const r=await fetch(url);
@@ -539,9 +543,21 @@ async function toggleScorecard(){
scorecardOpen=true; btn.textContent='Hide SCORECARD.md';
}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){
scorecardOpen=false;
document.getElementById('scBtn').textContent='View SCORECARD.md';
resetViewerToggles();
const dl=dlHref?`<a class="mini" style="margin-left:auto" href="${dlHref}" `+
`download="${esc(dlName||'report.txt')}" title="Download">⬇ download</a>`:'';
document.getElementById('coViewer').innerHTML=
+192
View File
@@ -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()
+3 -2
View File
@@ -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_7 } from './v_0_1_7'
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`). */
export const versions = VersionGraph.of({
current: v_0_1_8,
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],
current: v_0_1_9,
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],
})
+20
View File
@@ -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: {},
})