Implement BDEF v1.1 grading: scoring core, per-deck pipeline, ledger, dashboard, StartOS layer

- Deterministic scoring.py (quant 60 / qual 40 / flags -15, profitability heaviest)
- Per-company JSON ledger with forecast-target chaining deck N-1 -> N
- Single-shot sandbox agent with guided-JSON fallback ladder (no tool loop)
- Portfolio dashboard with sparklines, KPI hit rates, BDEF category bars
- 48 unit tests green; endpoints smoke-tested; npm check+build green

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jonathan Kirkwood
2026-07-06 14:15:12 -05:00
co-authored by Claude Fable 5
parent 1dde915540
commit b1d7aed9f4
48 changed files with 4907 additions and 971 deletions
+29
View File
@@ -0,0 +1,29 @@
{
"schema_version": 1,
"deck": {
"company_hint": "Acme Robotics",
"period": "2026-Q1",
"meeting_date": "2026-04-15",
"title": "Acme Robotics — Q1 2026 Board Deck",
"truncated": false
},
"kpis": [
{"name": "ARR", "canonical_name": "arr", "actual": 10.0, "unit": "$M", "period": "2026-Q1", "direction": "gte", "profitability": false, "target_in_deck": 9.5, "source": "slide 3, financial summary", "notes": ""},
{"name": "EBITDA Margin", "canonical_name": "ebitda_margin", "actual": -5.0, "unit": "%", "period": "2026-Q1", "direction": "gte", "profitability": true, "target_in_deck": -6.0, "source": "slide 4, P&L bridge", "notes": ""},
{"name": "Logo Churn", "canonical_name": "churn_rate", "actual": 4.0, "unit": "%", "period": "2026-Q1", "direction": "lte", "profitability": false, "target_in_deck": 5.0, "source": "slide 5, retention", "notes": ""},
{"name": "Cash Balance", "canonical_name": "cash_balance", "actual": 12.0, "unit": "$M", "period": "2026-Q1", "direction": "gte", "profitability": true, "target_in_deck": null, "source": "slide 4, balance sheet", "notes": ""}
],
"forward_targets": [
{"name": "ARR", "canonical_name": "arr", "target": 12.0, "unit": "$M", "target_period": "2026-Q2", "direction": "gte", "profitability": false, "source": "slide 9, guidance"},
{"name": "Logo Churn", "canonical_name": "churn_rate", "target": 4.0, "unit": "%", "target_period": "2026-Q2", "direction": "lte", "profitability": false, "source": "slide 9, guidance"},
{"name": "EBITDA Margin", "canonical_name": "ebitda_margin", "target": -2.0, "unit": "%", "target_period": "2026-Q2", "direction": "gte", "profitability": true, "source": "slide 9, guidance"},
{"name": "Qualified Pipeline", "canonical_name": "qualified_pipeline", "target": 30.0, "unit": "$M", "target_period": "2026-Q2", "direction": "gte", "profitability": false, "source": "slide 10, pipeline build"}
],
"red_flag_candidates": [
{"code": "hockey_stick_forecast", "description": "H2 revenue ramp shown with no downside case or stated falsifiers", "severity": 3, "evidence": "slide 9 guidance chart"}
],
"narrative": {
"summary": "Solid Q1: ARR beat plan at $10.0M, EBITDA margin improved to -5%, churn under plan. The H2 story rests entirely on the $30M qualified pipeline building as projected.",
"asks": ["Approve $2M expansion of the Austin integration facility"]
}
}
+26
View File
@@ -0,0 +1,26 @@
{
"schema_version": 1,
"deck": {
"company_hint": "Acme Robotics",
"period": "2026-Q2",
"meeting_date": "2026-07-14",
"title": "Acme Robotics — Q2 2026 Board Deck",
"truncated": false
},
"kpis": [
{"name": "ARR", "canonical_name": "arr", "actual": 11.0, "unit": "$M", "period": "2026-Q2", "direction": "gte", "profitability": false, "target_in_deck": null, "source": "slide 3, financial summary", "notes": ""},
{"name": "Logo Churn", "canonical_name": "churn_rate", "actual": 3.5, "unit": "%", "period": "2026-Q2", "direction": "lte", "profitability": false, "target_in_deck": null, "source": "slide 5, retention", "notes": ""},
{"name": "EBITDA Margin", "canonical_name": "ebitda_margin", "actual": -3.0, "unit": "%", "period": "2026-Q2", "direction": "gte", "profitability": true, "target_in_deck": null, "source": "slide 4, P&L bridge", "notes": ""},
{"name": "Cash Balance", "canonical_name": "cash_balance", "actual": 13.0, "unit": "$M", "period": "2026-Q2", "direction": "gte", "profitability": true, "target_in_deck": null, "source": "slide 4, balance sheet", "notes": ""}
],
"forward_targets": [
{"name": "ARR", "canonical_name": "arr", "target": 14.0, "unit": "$M", "target_period": "2026-Q3", "direction": "gte", "profitability": false, "source": "slide 9, guidance"}
],
"red_flag_candidates": [
{"code": "adjusted_metrics", "description": "EBITDA presented on an adjusted basis with no bridge to GAAP", "severity": 2, "evidence": "slide 4 footnote"}
],
"narrative": {
"summary": "Mixed Q2: ARR missed guidance at $11.0M vs $12.0M, churn beat, margin improved but missed the -2% target. Pipeline metric no longer reported.",
"asks": ["Approve revised FY2026 hiring plan"]
}
}
+103
View File
@@ -0,0 +1,103 @@
{
"schema_version": 1,
"grader": "grader-a",
"categories": [
{
"id": "A",
"score": 4,
"evidence": [
{
"quote": "Our compensation plan ties 60 percent of executive bonus to three-year ARR retention and free-cash-flow milestones rather than annual bookings, and every vice president now holds equity vesting over four years with a one-year cliff, which we believe aligns the team with long-term owners.",
"location": "slide 7"
}
],
"rationale": "Category A: specific, quantified disclosure with owner-aligned framing."
},
{
"id": "B",
"score": 4,
"evidence": [
{
"quote": "Our compensation plan ties 60 percent of executive bonus to three-year ARR retention and free-cash-flow milestones rather than annual bookings, and every vice president now holds equity vesting over four years with a one-year cliff, which we believe aligns the team with long-term owners.",
"location": "slide 7"
}
],
"rationale": "Category B: specific, quantified disclosure with owner-aligned framing."
},
{
"id": "C",
"score": 4,
"evidence": [
{
"quote": "Our compensation plan ties 60 percent of executive bonus to three-year ARR retention and free-cash-flow milestones rather than annual bookings, and every vice president now holds equity vesting over four years with a one-year cliff, which we believe aligns the team with long-term owners.",
"location": "slide 7"
}
],
"rationale": "Category C: specific, quantified disclosure with owner-aligned framing."
},
{
"id": "D",
"score": 4,
"evidence": [
{
"quote": "Our compensation plan ties 60 percent of executive bonus to three-year ARR retention and free-cash-flow milestones rather than annual bookings, and every vice president now holds equity vesting over four years with a one-year cliff, which we believe aligns the team with long-term owners.",
"location": "slide 7"
}
],
"rationale": "Category D: specific, quantified disclosure with owner-aligned framing."
},
{
"id": "E",
"score": 4,
"evidence": [
{
"quote": "Our compensation plan ties 60 percent of executive bonus to three-year ARR retention and free-cash-flow milestones rather than annual bookings, and every vice president now holds equity vesting over four years with a one-year cliff, which we believe aligns the team with long-term owners.",
"location": "slide 7"
}
],
"rationale": "Category E: specific, quantified disclosure with owner-aligned framing."
},
{
"id": "F",
"score": 4,
"evidence": [
{
"quote": "Our compensation plan ties 60 percent of executive bonus to three-year ARR retention and free-cash-flow milestones rather than annual bookings, and every vice president now holds equity vesting over four years with a one-year cliff, which we believe aligns the team with long-term owners.",
"location": "slide 7"
}
],
"rationale": "Category F: specific, quantified disclosure with owner-aligned framing."
},
{
"id": "G",
"score": 4,
"evidence": [
{
"quote": "Our compensation plan ties 60 percent of executive bonus to three-year ARR retention and free-cash-flow milestones rather than annual bookings, and every vice president now holds equity vesting over four years with a one-year cliff, which we believe aligns the team with long-term owners.",
"location": "slide 7"
}
],
"rationale": "Category G: specific, quantified disclosure with owner-aligned framing."
},
{
"id": "H",
"score": 2,
"evidence": [
{
"quote": "We ask the board to approve the revised hiring plan as presented; supporting detail is available from management upon request after the meeting, and we recommend approval without further discussion given the compressed agenda for this session.",
"location": "slide 11"
}
],
"rationale": "Asks are listed without recommendations or the inversion of the decision."
}
],
"red_flags": [
{
"code": "governance_gap",
"description": "succession and incentive redesign get one bullet while product minutiae fill nine slides",
"severity": 2,
"evidence": "slides 12-20"
}
],
"overall_comment": "Strong disclosure discipline overall; governance asks remain the weak spot."
}
+98
View File
@@ -0,0 +1,98 @@
{
"schema_version": 1,
"grader": "grader-b",
"categories": [
{
"id": "A",
"score": 4,
"evidence": [
{
"quote": "Our compensation plan ties 60 percent of executive bonus to three-year ARR retention and free-cash-flow milestones rather than annual bookings, and every vice president now holds equity vesting over four years with a one-year cliff, which we believe aligns the team with long-term owners.",
"location": "slide 7"
}
],
"rationale": "Category A: specific, quantified disclosure with owner-aligned framing."
},
{
"id": "B",
"score": 4,
"evidence": [
{
"quote": "Our compensation plan ties 60 percent of executive bonus to three-year ARR retention and free-cash-flow milestones rather than annual bookings, and every vice president now holds equity vesting over four years with a one-year cliff, which we believe aligns the team with long-term owners.",
"location": "slide 7"
}
],
"rationale": "Category B: specific, quantified disclosure with owner-aligned framing."
},
{
"id": "C",
"score": 4,
"evidence": [
{
"quote": "Our compensation plan ties 60 percent of executive bonus to three-year ARR retention and free-cash-flow milestones rather than annual bookings, and every vice president now holds equity vesting over four years with a one-year cliff, which we believe aligns the team with long-term owners.",
"location": "slide 7"
}
],
"rationale": "Category C: specific, quantified disclosure with owner-aligned framing."
},
{
"id": "D",
"score": 4,
"evidence": [
{
"quote": "Our compensation plan ties 60 percent of executive bonus to three-year ARR retention and free-cash-flow milestones rather than annual bookings, and every vice president now holds equity vesting over four years with a one-year cliff, which we believe aligns the team with long-term owners.",
"location": "slide 7"
}
],
"rationale": "Category D: specific, quantified disclosure with owner-aligned framing."
},
{
"id": "E",
"score": 4,
"evidence": [
{
"quote": "Our compensation plan ties 60 percent of executive bonus to three-year ARR retention and free-cash-flow milestones rather than annual bookings, and every vice president now holds equity vesting over four years with a one-year cliff, which we believe aligns the team with long-term owners.",
"location": "slide 7"
}
],
"rationale": "Category E: specific, quantified disclosure with owner-aligned framing."
},
{
"id": "F",
"score": 4,
"evidence": [
{
"quote": "Our compensation plan ties 60 percent of executive bonus to three-year ARR retention and free-cash-flow milestones rather than annual bookings, and every vice president now holds equity vesting over four years with a one-year cliff, which we believe aligns the team with long-term owners.",
"location": "slide 7"
}
],
"rationale": "Category F: specific, quantified disclosure with owner-aligned framing."
},
{
"id": "G",
"score": 4,
"evidence": [
{
"quote": "Our compensation plan ties 60 percent of executive bonus to three-year ARR retention and free-cash-flow milestones rather than annual bookings, and every vice president now holds equity vesting over four years with a one-year cliff, which we believe aligns the team with long-term owners.",
"location": "slide 7"
}
],
"rationale": "Category G: specific, quantified disclosure with owner-aligned framing."
},
{
"id": "H",
"score": 3,
"evidence": [],
"rationale": "Asks are listed without recommendations or the inversion of the decision."
}
],
"red_flags": [
{
"code": "governance_gap",
"description": "board asks lack recommendations and inversion",
"severity": 3,
"evidence": "slide 11"
}
],
"overall_comment": "Strong disclosure discipline overall; governance asks remain the weak spot."
}
+96
View File
@@ -0,0 +1,96 @@
{
"schema_version": 1,
"grader": "grader-c",
"categories": [
{
"id": "A",
"score": 4,
"evidence": [
{
"quote": "Our compensation plan ties 60 percent of executive bonus to three-year ARR retention and free-cash-flow milestones rather than annual bookings, and every vice president now holds equity vesting over four years with a one-year cliff, which we believe aligns the team with long-term owners.",
"location": "slide 7"
}
],
"rationale": "Category A: specific, quantified disclosure with owner-aligned framing."
},
{
"id": "B",
"score": 4,
"evidence": [
{
"quote": "Our compensation plan ties 60 percent of executive bonus to three-year ARR retention and free-cash-flow milestones rather than annual bookings, and every vice president now holds equity vesting over four years with a one-year cliff, which we believe aligns the team with long-term owners.",
"location": "slide 7"
}
],
"rationale": "Category B: specific, quantified disclosure with owner-aligned framing."
},
{
"id": "C",
"score": 4,
"evidence": [
{
"quote": "Our compensation plan ties 60 percent of executive bonus to three-year ARR retention and free-cash-flow milestones rather than annual bookings, and every vice president now holds equity vesting over four years with a one-year cliff, which we believe aligns the team with long-term owners.",
"location": "slide 7"
}
],
"rationale": "Category C: specific, quantified disclosure with owner-aligned framing."
},
{
"id": "D",
"score": 4,
"evidence": [
{
"quote": "Our compensation plan ties 60 percent of executive bonus to three-year ARR retention and free-cash-flow milestones rather than annual bookings, and every vice president now holds equity vesting over four years with a one-year cliff, which we believe aligns the team with long-term owners.",
"location": "slide 7"
}
],
"rationale": "Category D: specific, quantified disclosure with owner-aligned framing."
},
{
"id": "E",
"score": 4,
"evidence": [
{
"quote": "Our compensation plan ties 60 percent of executive bonus to three-year ARR retention and free-cash-flow milestones rather than annual bookings, and every vice president now holds equity vesting over four years with a one-year cliff, which we believe aligns the team with long-term owners.",
"location": "slide 7"
}
],
"rationale": "Category E: specific, quantified disclosure with owner-aligned framing."
},
{
"id": "F",
"score": 4,
"evidence": [
{
"quote": "Our compensation plan ties 60 percent of executive bonus to three-year ARR retention and free-cash-flow milestones rather than annual bookings, and every vice president now holds equity vesting over four years with a one-year cliff, which we believe aligns the team with long-term owners.",
"location": "slide 7"
}
],
"rationale": "Category F: specific, quantified disclosure with owner-aligned framing."
},
{
"id": "G",
"score": 4,
"evidence": [
{
"quote": "Our compensation plan ties 60 percent of executive bonus to three-year ARR retention and free-cash-flow milestones rather than annual bookings, and every vice president now holds equity vesting over four years with a one-year cliff, which we believe aligns the team with long-term owners.",
"location": "slide 7"
}
],
"rationale": "Category G: specific, quantified disclosure with owner-aligned framing."
},
{
"id": "H",
"score": 2,
"evidence": [
{
"quote": "We ask the board to approve the revised hiring plan as presented; supporting detail is available from management upon request after the meeting, and we recommend approval without further discussion given the compressed agenda for this session.",
"location": "slide 11"
}
],
"rationale": "Asks are listed without recommendations or the inversion of the decision."
}
],
"red_flags": [],
"overall_comment": "Strong disclosure discipline overall; governance asks remain the weak spot."
}
+118
View File
@@ -0,0 +1,118 @@
"""Tests for decks.py: slugify, period parsing/sorting, inbox discovery."""
import os
import sys
import tempfile
import unittest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import decks
class TestSlugify(unittest.TestCase):
def test_basic(self):
self.assertEqual(decks.slugify("Acme Robotics"), "acme-robotics")
self.assertEqual(decks.slugify(" Acme, Inc. (US) "), "acme-inc-us")
self.assertEqual(decks.slugify("ALLCAPS"), "allcaps")
self.assertEqual(decks.slugify(""), "company")
self.assertEqual(decks.slugify("---"), "company")
class TestParsePeriod(unittest.TestCase):
def test_quarters(self):
self.assertEqual(decks.parse_period_from_name("acme_2026-Q2_board.pdf"), "2026-Q2")
self.assertEqual(decks.parse_period_from_name("2026Q4 deck.pptx"), "2026-Q4")
self.assertEqual(decks.parse_period_from_name("Q3 2025 update.pptx"), "2025-Q3")
self.assertEqual(decks.parse_period_from_name("q1_2024_board.docx"), "2024-Q1")
def test_halves(self):
self.assertEqual(decks.parse_period_from_name("board-2026-H1.docx"), "2026-H1")
self.assertEqual(decks.parse_period_from_name("2025H2-review.pdf"), "2025-H2")
def test_months(self):
self.assertEqual(decks.parse_period_from_name("acme 2026-05 board.pdf"), "2026-05")
self.assertEqual(decks.parse_period_from_name("2026_12_flash.txt"), "2026-12")
self.assertIsNone(decks.parse_period_from_name("2026-13 notes.pdf"))
self.assertIsNone(decks.parse_period_from_name("2026-00 notes.pdf"))
def test_fiscal_year(self):
self.assertEqual(decks.parse_period_from_name("FY2025 review.pdf"), "FY2025")
self.assertEqual(decks.parse_period_from_name("fy-2024 plan.txt"), "FY2024")
self.assertEqual(decks.parse_period_from_name("FY 2026 budget.docx"), "FY2026")
def test_no_period(self):
self.assertIsNone(decks.parse_period_from_name("notes.txt"))
self.assertIsNone(decks.parse_period_from_name("budget_2027.xlsx"))
self.assertIsNone(decks.parse_period_from_name("Q5 2026.pdf"))
def test_quarter_wins_over_month(self):
# "2026-Q2" must not be misread; Q pattern is checked before YYYY-MM.
self.assertEqual(decks.parse_period_from_name("2026-Q2 and 2026-05.pdf"), "2026-Q2")
def test_not_inside_digit_runs(self):
self.assertIsNone(decks.parse_period_from_name("doc-20261-05.pdf"))
class TestPeriodSortKey(unittest.TestCase):
def test_ordering_mixed_granularities(self):
ordered = ["FY2025", "2025-Q4", "2026-H1", "2026-Q1", "2026-01",
"2026-Q2", "2026-05", "2026-H2", "2026-Q4"]
self.assertEqual(sorted(ordered, key=decks.period_sort_key), ordered)
def test_start_months(self):
self.assertEqual(decks.period_sort_key("2026-Q2")[:2], (2026, 4))
self.assertEqual(decks.period_sort_key("2026-H2")[:2], (2026, 7))
self.assertEqual(decks.period_sort_key("2026-11")[:2], (2026, 11))
self.assertEqual(decks.period_sort_key("FY2026")[:2], (2026, 1))
def test_unknown_sorts_last(self):
keys = [decks.period_sort_key(p) for p in ("2026-Q4", None, "garbage", "FY2026")]
self.assertEqual(max(keys), decks.period_sort_key(None))
self.assertEqual(decks.period_sort_key(None), decks.period_sort_key("garbage"))
self.assertGreater(decks.period_sort_key(None), decks.period_sort_key("2099-Q4"))
class TestDiscover(unittest.TestCase):
def _touch(self, *parts):
path = os.path.join(*parts)
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w") as f:
f.write("x")
def test_discover(self):
with tempfile.TemporaryDirectory() as tmp:
inbox = os.path.join(tmp, "inbox")
self._touch(inbox, "Acme Robotics", "acme-2026-Q1.pdf")
self._touch(inbox, "Acme Robotics", "acme-2026-Q1-appendix.txt")
self._touch(inbox, "Acme Robotics", "acme-2026-Q2.pptx")
self._touch(inbox, "Acme Robotics", "chart-2026-Q2.png")
self._touch(inbox, "Acme Robotics", "notes.txt")
self._touch(inbox, "Acme Robotics", ".DS_Store")
self._touch(inbox, "beta-corp", "deck 2026-H1.docx")
self._touch(inbox, "stray.pdf")
out = decks.discover(inbox)
self.assertEqual(out["skipped"], ["stray.pdf"])
units = out["units"]
keys = [(u["company_slug"], u["period"], u["period_source"]) for u in units]
self.assertEqual(keys, [
("acme-robotics", "2026-Q1", "filename"),
("acme-robotics", "2026-Q2", "filename"),
("acme-robotics", None, "unknown"),
("beta-corp", "2026-H1", "filename"),
])
q1 = units[0]
self.assertEqual([os.path.basename(f) for f in q1["files"]],
["acme-2026-Q1-appendix.txt", "acme-2026-Q1.pdf"])
self.assertTrue(all(os.path.isabs(f) for f in q1["files"]))
q2 = units[1]
self.assertEqual([os.path.basename(f) for f in q2["files"]], ["acme-2026-Q2.pptx"])
self.assertEqual(q2["ignored"], ["chart-2026-Q2.png"])
self.assertEqual([os.path.basename(f) for f in units[2]["files"]], ["notes.txt"])
def test_missing_inbox(self):
self.assertEqual(decks.discover("/nonexistent/inbox"), {"units": [], "skipped": []})
if __name__ == "__main__":
unittest.main()
+116
View File
@@ -0,0 +1,116 @@
"""Tests for ledger.py: company lifecycle, deck records, forward targets."""
import glob
import os
import sys
import tempfile
import unittest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import ledger as ledger_mod
def _record(deck_id, period, composite=70.0):
return {"schema_version": 1, "deck_id": deck_id, "period": period,
"composite": composite, "graded_at": "2026-07-01T00:00:00Z"}
def _ft(canonical, target, target_period, direction="gte"):
return {"name": canonical, "canonical_name": canonical, "target": target,
"unit": "", "target_period": target_period, "direction": direction,
"profitability": False, "source": "slide 9"}
class TestLedger(unittest.TestCase):
def setUp(self):
self._tmp = tempfile.TemporaryDirectory()
self.ledger = ledger_mod.Ledger(os.path.join(self._tmp.name, "ledger"))
def tearDown(self):
self._tmp.cleanup()
def test_ensure_company_auto_created(self):
c = self.ledger.ensure_company("acme")
self.assertTrue(c["auto_created"])
self.assertEqual(c["name"], "acme")
c2 = self.ledger.ensure_company("acme", name="Acme Robotics")
self.assertEqual(c2["name"], "acme") # existing entry wins
named = self.ledger.ensure_company("beta", name="Beta Corp")
self.assertFalse(named["auto_created"])
self.assertEqual(named["name"], "Beta Corp")
self.assertEqual(self.ledger.all_slugs(), ["acme", "beta"])
self.assertEqual(len(self.ledger.all_companies()), 2)
def test_merge_config_companies(self):
self.ledger.ensure_company("acme")
self.ledger.merge_config_companies([{
"slug": "acme", "name": "Acme Robotics",
"kpiAliases": "arr=annual recurring revenue;run_rate_arr\nchurn_rate=logo_churn",
"pinnedTargets": [{"kpi": "cash_balance", "target": 12.0, "unit": "$M",
"direction": "gte", "profitability": True}],
}])
c = self.ledger.get_company("acme")
self.assertFalse(c["auto_created"])
self.assertEqual(c["name"], "Acme Robotics")
self.assertEqual(c["kpi_aliases"],
{"arr": ["annual recurring revenue", "run_rate_arr"],
"churn_rate": ["logo_churn"]})
self.assertEqual(c["pinned_targets"][0]["kpi"], "cash_balance")
# slug derived from name when absent
self.ledger.merge_config_companies([{"name": "Beta Corp", "kpiAliases": "",
"pinnedTargets": []}])
self.assertIsNotNone(self.ledger.get_company("beta-corp"))
def test_record_supersede_prior_targets_roundtrip(self):
path = self.ledger.record_deck("acme", _record("2026-Q1", "2026-Q1", 82.7),
[_ft("arr", 12.0, "2026-Q2"),
_ft("arr", 15.0, "2026-Q3")])
self.assertTrue(os.path.isfile(path))
self.assertEqual(
[t["target"] for t in self.ledger.prior_targets("acme", "2026-Q2")], [12.0])
self.assertEqual(self.ledger.prior_targets("acme", "2026-Q4"), [])
self.assertEqual(self.ledger.prior_targets("nobody", "2026-Q2"), [])
# Re-grade the same deck: old record superseded (renamed), one live record.
self.ledger.record_deck("acme", _record("2026-Q1", "2026-Q1", 80.0),
[_ft("arr", 12.5, "2026-Q2")])
ddir = os.path.dirname(path)
self.assertEqual(len(glob.glob(os.path.join(ddir, "*.superseded-*.json"))), 1)
live = self.ledger.deck_records("acme")
self.assertEqual(len(live), 1)
self.assertEqual(live[0]["composite"], 80.0)
self.assertEqual(
[t["target"] for t in self.ledger.prior_targets("acme", "2026-Q2")], [12.5])
# history keeps one entry per period
c = self.ledger.get_company("acme")
self.assertEqual([h["period"] for h in c["history"]], ["2026-Q1"])
self.assertEqual(c["history"][0]["composite"], 80.0)
def test_newer_deck_replaces_targets_older_does_not(self):
self.ledger.record_deck("acme", _record("2026-Q1", "2026-Q1"),
[_ft("arr", 15.0, "2026-Q3")])
self.ledger.record_deck("acme", _record("2026-Q2", "2026-Q2"),
[_ft("arr", 16.0, "2026-Q3"),
_ft("churn_rate", 3.0, "2026-Q3", "lte")])
targets = self.ledger.prior_targets("acme", "2026-Q3")
self.assertEqual(sorted(t["target"] for t in targets), [3.0, 16.0])
c = self.ledger.get_company("acme")
self.assertEqual(c["extracted_targets"]["2026-Q3"]["from_deck"], "2026-Q2")
# Re-recording the OLDER deck must not clobber the newer deck's targets.
self.ledger.record_deck("acme", _record("2026-Q1", "2026-Q1"),
[_ft("arr", 15.0, "2026-Q3")])
targets = self.ledger.prior_targets("acme", "2026-Q3")
self.assertEqual(sorted(t["target"] for t in targets), [3.0, 16.0])
# History is sorted oldest first.
c = self.ledger.get_company("acme")
self.assertEqual([h["period"] for h in c["history"]], ["2026-Q1", "2026-Q2"])
def test_deck_record_lookup(self):
self.ledger.record_deck("acme", _record("2026-Q1", "2026-Q1"), [])
self.assertEqual(self.ledger.deck_record("acme", "2026-Q1")["period"], "2026-Q1")
self.assertIsNone(self.ledger.deck_record("acme", "2026-Q9"))
self.assertEqual(self.ledger.deck_records("nobody"), [])
if __name__ == "__main__":
unittest.main()
+423
View File
@@ -0,0 +1,423 @@
"""Tests for scoring.py (pure scorer), validate.py, and the fixture-driven
Q1 -> Q2 end-to-end flow through the ledger and scorecard renderers."""
import json
import os
import sys
import tempfile
import unittest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import ledger as ledger_mod
import scorecard
import scoring
import validate
FIXTURES = os.path.join(os.path.dirname(os.path.abspath(__file__)), "fixtures")
WEIGHTS = {
"profitabilityKpi": 30, "otherKpi": 20, "forecastIntegrity": 10,
"qualCategoryMax": 5, "redFlagCap": 15, "kpiCreditFloor": 0.5,
"droppedKpiPenalty": 2, "droppedKpiMax": 3, "evidenceFullCredit": 400,
"singleSourceFlagFactor": 0.5,
}
PINNED_CASH = [{"kpi": "cash_balance", "target": 12.0, "unit": "$M",
"direction": "gte", "profitability": True}]
def _fixture(name):
with open(os.path.join(FIXTURES, name), encoding="utf-8") as f:
return json.load(f)
def _kpi(canonical, actual, direction="gte", prof=False, tid=None, name=None, unit=""):
return {"name": name or canonical, "canonical_name": canonical, "actual": actual,
"unit": unit, "period": None, "direction": direction, "profitability": prof,
"target_in_deck": tid, "source": "slide 1", "notes": ""}
def _ft(canonical, target, direction="gte", target_period="2026-Q2", prof=False):
return {"name": canonical, "canonical_name": canonical, "target": target,
"unit": "", "target_period": target_period, "direction": direction,
"profitability": prof, "source": "slide 9"}
def _extraction(kpis=None, forward=None, flags=None, period="2026-Q2"):
return {"schema_version": 1, "deck": {"period": period},
"kpis": kpis or [], "forward_targets": forward or [],
"red_flag_candidates": flags or [],
"narrative": {"summary": "test deck", "asks": []}}
def _grade(grader="grader-a", score=3, quote_chars=0, red_flags=None, overrides=None):
cats = []
for cid in "ABCDEFGH":
s, qc = score, quote_chars
if overrides and cid in overrides:
s, qc = overrides[cid]
ev = [{"quote": "q" * qc, "location": "slide 1"}] if qc else []
cats.append({"id": cid, "score": s, "evidence": ev, "rationale": f"cat {cid}"})
return {"schema_version": 1, "grader": grader, "categories": cats,
"red_flags": red_flags or [], "overall_comment": "ok"}
def _meta(period="2026-Q2", deck_id="d1"):
return {"company": "acme", "period": period, "deck_id": deck_id, "job_id": "job-1",
"graded_at": "2026-07-06T12:00:00Z",
"panel": [{"rid": "grader-a", "model": "grader-a", "valid": True}],
"artifacts": {"extraction": "extraction.json"}}
def _score(extraction, grades=None, pinned=None, prior=None, aliases=None, meta=None):
return scoring.score_deck(extraction, grades if grades is not None else [_grade()],
pinned or [], prior or [], aliases or {}, WEIGHTS,
meta or _meta())
def _flag(rec, code):
return [f for f in rec["penalties"]["flags"] if f["code"] == code]
class TestMatchKpi(unittest.TestCase):
def test_exact(self):
cand, via = scoring.match_kpi("arr", [{"canonical_name": "arr", "name": "ARR"}], {})
self.assertEqual(via, "exact")
self.assertEqual(cand["name"], "ARR")
def test_alias_forward_and_reverse(self):
aliases = {"arr": ["Annual Recurring Revenue", "run_rate_arr"]}
cand, via = scoring.match_kpi(
"arr", [{"canonical_name": "revenue_annualized",
"name": "Annual Recurring Revenue"}], aliases)
self.assertEqual(via, "alias")
cand, via = scoring.match_kpi(
"run_rate_arr", [{"canonical_name": "arr", "name": "ARR"}], aliases)
self.assertEqual(via, "alias")
def test_fuzzy(self):
cand, via = scoring.match_kpi(
"ebitda_margin", [{"canonical_name": "ebitda_margins", "name": "x"}], {})
self.assertEqual(via, "fuzzy")
def test_no_match(self):
self.assertEqual(
scoring.match_kpi("arr", [{"canonical_name": "cash_balance", "name": "Cash"}], {}),
(None, None))
self.assertEqual(scoring.match_kpi("", [{"canonical_name": "arr"}], {}), (None, None))
class TestCredit(unittest.TestCase):
def test_lte_credit(self):
rec = _score(_extraction([_kpi("churn_rate", 6.0, "lte", tid=5.0)]))
self.assertAlmostEqual(rec["kpi_results"][0]["credit"], 0.6667, places=4)
rec = _score(_extraction([_kpi("churn_rate", 4.0, "lte", tid=5.0)]))
self.assertEqual(rec["kpi_results"][0]["credit"], 1.0)
def test_floor(self):
rec = _score(_extraction([_kpi("arr", 4.0, tid=10.0)])) # r=0.4 < floor
self.assertEqual(rec["kpi_results"][0]["credit"], 0.0)
rec = _score(_extraction([_kpi("arr", 7.5, tid=10.0)])) # r=0.75 -> 0.5
self.assertAlmostEqual(rec["kpi_results"][0]["credit"], 0.5, places=4)
def test_guards(self):
self.assertEqual(scoring._credit(5, 0, "gte", 0.5), 1.0) # zero target, passes
self.assertEqual(scoring._credit(-5, 0, "gte", 0.5), 0.0) # zero target, fails
self.assertEqual(scoring._credit(-1, 1, "gte", 0.5), 0.0) # sign mismatch, fails
self.assertEqual(scoring._credit(1, -1, "gte", 0.5), 1.0) # sign mismatch, passes
self.assertEqual(scoring._credit(0, 5, "lte", 0.5), 1.0) # lte zero actual
def test_negative_targets(self):
# EBITDA margin: target -2, actual -3 -> two thirds of the way -> 0.3333
self.assertAlmostEqual(scoring._credit(-3, -2, "gte", 0.5), 1 / 3, places=4)
self.assertEqual(scoring._credit(-1, -2, "gte", 0.5), 1.0)
class TestQuantBuckets(unittest.TestCase):
def test_first_deck_renormalization(self):
# No prior targets -> forecast NA -> its 10 points redistribute 36/24.
rec = _score(_extraction([_kpi("ebitda_margin", 5.0, prof=True, tid=5.0),
_kpi("arr", 10.0, tid=10.0)]))
q = rec["quant"]
self.assertTrue(q["forecast_integrity"]["na"])
self.assertAlmostEqual(q["profitability"]["weight"], 36.0)
self.assertAlmostEqual(q["profitability"]["score"], 36.0)
self.assertAlmostEqual(q["other"]["weight"], 24.0)
self.assertAlmostEqual(q["other"]["score"], 24.0)
self.assertAlmostEqual(q["score"], 60.0)
self.assertEqual(rec["penalties"]["flags"], [])
self.assertAlmostEqual(rec["composite"], 84.0) # 60 quant + 24 qual (all 3s)
def test_forecast_integrity_second_deck(self):
prior = [_ft("arr", 12.0), _ft("churn_rate", 4.0, "lte")]
rec = _score(_extraction([_kpi("arr", 11.0), _kpi("churn_rate", 3.5, "lte"),
_kpi("fcf", 1.0, prof=True, tid=1.0)]),
prior=prior)
fi = rec["quant"]["forecast_integrity"]
self.assertFalse(fi["na"])
self.assertEqual(fi["weight"], 10.0)
self.assertEqual(fi["kpi_count"], 2)
accs = {f["canonical_name"]: f["accuracy"] for f in rec["forecast_results"]}
self.assertAlmostEqual(accs["arr"], 0.9167, places=4) # 1/12 undershoot
self.assertAlmostEqual(accs["churn_rate"], 0.9375, places=4) # overshoot halved
self.assertAlmostEqual(fi["score"], (0.9167 + 0.9375) / 2 * 10, places=3)
def test_no_profitability_flag_and_redistribution(self):
rec = _score(_extraction([_kpi("arr", 10.0, tid=10.0)]))
q = rec["quant"]
self.assertTrue(q["profitability"]["na"])
self.assertTrue(q["forecast_integrity"]["na"])
self.assertAlmostEqual(q["other"]["weight"], 60.0)
self.assertAlmostEqual(q["score"], 60.0)
flags = _flag(rec, "no_profitability_visibility")
self.assertEqual(len(flags), 1)
self.assertEqual(flags[0]["points"], 3.0) # scoring flags never damped
self.assertEqual(flags[0]["sources"], ["scoring"])
def test_profitability_kpis_without_targets_na_no_flag(self):
rec = _score(_extraction([_kpi("ebitda_margin", -5.0, prof=True),
_kpi("arr", 10.0, tid=10.0)]))
self.assertTrue(rec["quant"]["profitability"]["na"])
self.assertEqual(_flag(rec, "no_profitability_visibility"), [])
def test_all_quant_na_scales_qual(self):
rec = _score(_extraction([]))
# qual 24 (all 3s) scaled to 60, minus no_profitability(3) + no_quantitative(4)
self.assertTrue(all(rec["quant"][b]["na"] for b in
("profitability", "other", "forecast_integrity")))
self.assertEqual(len(_flag(rec, "no_quantitative_kpis")), 1)
self.assertAlmostEqual(rec["composite"], 53.0)
class TestTargetPrecedence(unittest.TestCase):
def test_pinned_beats_extracted_beats_in_deck(self):
kpis = [_kpi("arr", 11.0, tid=9.0)]
pinned = [{"kpi": "arr", "target": 10.0, "unit": "$M",
"direction": "gte", "profitability": False}]
prior = [_ft("arr", 12.0)]
r = _score(_extraction(kpis), pinned=pinned, prior=prior)["kpi_results"][0]
self.assertEqual((r["target"], r["target_source"], r["matched_via"]),
(10.0, "pinned", "exact"))
self.assertEqual(r["credit"], 1.0)
r = _score(_extraction(kpis), prior=prior)["kpi_results"][0]
self.assertEqual((r["target"], r["target_source"]), (12.0, "extracted"))
self.assertAlmostEqual(r["credit"], 0.8333, places=4)
r = _score(_extraction(kpis))["kpi_results"][0]
self.assertEqual((r["target"], r["target_source"], r["matched_via"]),
(9.0, "in_deck", None))
def test_untargeted_kpi_reported_with_none(self):
r = _score(_extraction([_kpi("nps", 40.0)]))["kpi_results"][0]
self.assertIsNone(r["target"])
self.assertIsNone(r["credit"])
self.assertIsNone(r["target_source"])
class TestQualitative(unittest.TestCase):
def test_evidence_regression_both_directions(self):
# Median 5 with no quotes regresses to 3; so does median 1.
rec = _score(_extraction([]), grades=[_grade(score=5, quote_chars=0)])
self.assertEqual(rec["qual"]["categories"]["A"]["adjusted"], 3.0)
rec = _score(_extraction([]), grades=[_grade(score=1, quote_chars=0)])
self.assertEqual(rec["qual"]["categories"]["A"]["adjusted"], 3.0)
self.assertAlmostEqual(rec["qual"]["score"], 24.0)
def test_full_evidence_keeps_extreme_scores(self):
# Per-quote chars cap at 200, so full credit (400) needs two quotes.
g = _grade(score=5, quote_chars=200)
for cat in g["categories"]:
cat["evidence"].append({"quote": "q" * 200, "location": "slide 2"})
rec = _score(_extraction([]), grades=[g])
cat = rec["qual"]["categories"]["A"]
self.assertEqual(cat["evidence_quality"], 1.0)
self.assertEqual(cat["adjusted"], 5.0)
self.assertEqual(cat["points"], 5.0)
def test_quote_chars_capped_at_200_each(self):
# One 1000-char quote counts as 200 -> e = 0.5 -> adjusted 4.
rec = _score(_extraction([]), grades=[_grade(score=5, quote_chars=1000)])
self.assertEqual(rec["qual"]["categories"]["A"]["evidence_quality"], 0.5)
self.assertEqual(rec["qual"]["categories"]["A"]["adjusted"], 4.0)
def test_panel_median_and_rationales(self):
grades = [_grade("g1", score=4, quote_chars=400),
_grade("g2", score=4, quote_chars=400),
_grade("g3", score=2, quote_chars=400)]
rec = _score(_extraction([]), grades=grades)
cat = rec["qual"]["categories"]["B"]
self.assertEqual(cat["panel_scores"], [4, 4, 2])
self.assertEqual(cat["median"], 4.0)
self.assertEqual(len(cat["rationales"]), 3)
self.assertEqual(cat["rationales"][0]["grader"], "g1")
class TestPenalties(unittest.TestCase):
def test_single_source_damping(self):
rec = _score(_extraction([], flags=[{"code": "adjusted_metrics",
"description": "d", "severity": 4}]))
f = _flag(rec, "adjusted_metrics")[0]
self.assertEqual(f["points"], 2.0)
self.assertEqual(f["sources"], ["extractor"])
def test_two_sources_full_severity_max_wins(self):
grades = [_grade("g1", red_flags=[{"code": "governance_gap",
"description": "weak", "severity": 2}]),
_grade("g2", red_flags=[{"code": "governance_gap",
"description": "worse", "severity": 3}])]
rec = _score(_extraction([]), grades=grades)
f = _flag(rec, "governance_gap")[0]
self.assertEqual(f["severity"], 3)
self.assertEqual(f["points"], 3.0)
self.assertEqual(f["sources"], ["g1", "g2"])
def test_penalty_cap(self):
codes = ["related_party", "channel_stuffing_risk", "suppressed_dissent",
"metric_redefinition"]
flags = [{"code": c, "description": c, "severity": 5} for c in codes]
rec = _score(_extraction([_kpi("fcf", 1.0, prof=True, tid=1.0)], flags=flags),
grades=[_grade("g1", red_flags=flags)])
self.assertEqual(rec["penalties"]["total"], 15.0) # 4x5=20 capped
def test_dropped_kpi_flags_capped(self):
prior = [_ft(c, 1.0) for c in ("alpha_metric", "beta_metric", "gamma_metric",
"delta_metric", "epsilon_metric")]
rec = _score(_extraction([]), prior=prior)
dropped = _flag(rec, "kpi_dropped")
self.assertEqual(len(dropped), 3) # droppedKpiMax
for f in dropped:
self.assertEqual(f["points"], 2.0) # droppedKpiPenalty, never damped
class TestValidate(unittest.TestCase):
def test_parse_json_text(self):
self.assertEqual(validate.parse_json_text('{"a": 1}'), {"a": 1})
salvaged = validate.parse_json_text(
'Sure! Here is the JSON:\n```json\n{"a": {"b": "}"}}\n```\ntrailing prose')
self.assertEqual(salvaged, {"a": {"b": "}"}})
self.assertIsNone(validate.parse_json_text("no json here"))
self.assertIsNone(validate.parse_json_text("[1, 2, 3]"))
self.assertIsNone(validate.parse_json_text(""))
def test_schemas_load_and_fixtures_validate(self):
self.assertIn("properties", validate.load_schema("extraction"))
self.assertIn("properties", validate.load_schema("grades"))
for name, schema in (("extraction_q1.json", "extraction"),
("extraction_q2.json", "extraction"),
("grade_a.json", "grades"), ("grade_b.json", "grades"),
("grade_c.json", "grades")):
err = validate.validate_obj(_fixture(name), schema)
self.assertIsNone(err, f"{name}: {err}")
def test_validate_obj_rejects_bad(self):
self.assertIsNotNone(validate.validate_obj({"schema_version": 1}, "grades"))
def test_validate_file(self):
obj, err = validate.validate_file(os.path.join(FIXTURES, "grade_a.json"), "grades")
self.assertIsNone(err)
self.assertEqual(obj["grader"], "grader-a")
obj, err = validate.validate_file("/nonexistent.json", "grades")
self.assertIsNone(obj)
self.assertIsNotNone(err)
class TestEndToEnd(unittest.TestCase):
"""Fixture-driven Q1 -> Q2 flow: score, ledger round-trip, rendering."""
def setUp(self):
self._tmp = tempfile.TemporaryDirectory()
self.ledger = ledger_mod.Ledger(os.path.join(self._tmp.name, "ledger"))
self.grades = [_fixture("grade_a.json"), _fixture("grade_b.json"),
_fixture("grade_c.json")]
self.q1 = _fixture("extraction_q1.json")
self.q2 = _fixture("extraction_q2.json")
def tearDown(self):
self._tmp.cleanup()
def _score_q1(self):
return scoring.score_deck(self.q1, self.grades, PINNED_CASH, [], {}, WEIGHTS,
_meta("2026-Q1", "2026-Q1"))
def test_q1_first_deck(self):
rec = self._score_q1()
q = rec["quant"]
self.assertTrue(q["forecast_integrity"]["na"])
self.assertAlmostEqual(q["score"], 60.0) # every KPI at/above target
# qual: A-G 3.5 pts each (median 4, evidence 0.5), H 2.6667
self.assertAlmostEqual(rec["qual"]["score"], 27.1667, places=3)
self.assertAlmostEqual(rec["qual"]["categories"]["H"]["points"], 2.6667, places=3)
# hockey_stick (extractor only, sev 3 -> 1.5) + governance_gap (2 graders -> 3)
self.assertAlmostEqual(rec["penalties"]["total"], 4.5)
self.assertAlmostEqual(rec["composite"], 82.7)
cash = next(k for k in rec["kpi_results"] if k["canonical_name"] == "cash_balance")
self.assertEqual(cash["target_source"], "pinned")
def test_q2_against_q1_targets(self):
rec1 = self._score_q1()
self.ledger.record_deck("acme", rec1, self.q1["forward_targets"])
prior = self.ledger.prior_targets("acme", "2026-Q2")
self.assertEqual(len(prior), 4)
rec2 = scoring.score_deck(self.q2, self.grades, PINNED_CASH, prior, {}, WEIGHTS,
_meta("2026-Q2", "2026-Q2"))
by_name = {k["canonical_name"]: k for k in rec2["kpi_results"]}
self.assertAlmostEqual(by_name["arr"]["credit"], 0.8333, places=4)
self.assertEqual(by_name["churn_rate"]["credit"], 1.0)
self.assertAlmostEqual(by_name["ebitda_margin"]["credit"], 0.3333, places=4)
self.assertEqual(by_name["cash_balance"]["target_source"], "pinned")
self.assertEqual(by_name["cash_balance"]["credit"], 1.0)
q = rec2["quant"]
self.assertAlmostEqual(q["profitability"]["score"], 20.0, places=2)
self.assertAlmostEqual(q["other"]["score"], 18.333, places=2)
self.assertAlmostEqual(q["forecast_integrity"]["score"], 7.847, places=2)
self.assertEqual(len(rec2["forecast_results"]), 3)
# qualified_pipeline guided in Q1 but not reported in Q2 -> dropped flag
dropped = _flag(rec2, "kpi_dropped")
self.assertEqual(len(dropped), 1)
self.assertIn("qualified_pipeline", dropped[0]["description"])
# adjusted_metrics 1.0 + governance_gap 3.0 + kpi_dropped 2.0
self.assertAlmostEqual(rec2["penalties"]["total"], 6.0)
self.assertAlmostEqual(rec2["composite"], 67.3)
self.assertAlmostEqual(
rec2["composite"],
round(q["score"] + rec2["qual"]["score"] - rec2["penalties"]["total"], 1))
# ledger round-trip + rendering
self.ledger.record_deck("acme", rec2, self.q2["forward_targets"])
records = self.ledger.deck_records("acme")
self.assertEqual([r["period"] for r in records], ["2026-Q1", "2026-Q2"])
report = scorecard.render_deck_report(rec2, self.q2, adjudication_md="Chair memo.")
self.assertIn("67.3", report)
self.assertIn("pinned", report)
self.assertIn("## Panel adjudication", report)
self.assertIn("Chair memo.", report)
self.assertIn("kpi_dropped", report)
card = scorecard.render_scorecard(self.ledger.get_company("acme"), records)
self.assertIn("2026-Q1", card)
self.assertIn("2026-Q2", card)
self.assertIn("", card) # composite fell Q1 -> Q2
self.assertIn("KPI hit-rate", card)
self.assertIn("arr", card)
def test_meta_passthrough_and_record_shape(self):
rec = self._score_q1()
self.assertEqual(rec["company"], "acme")
self.assertEqual(rec["deck_id"], "2026-Q1")
self.assertEqual(rec["job_id"], "job-1")
self.assertEqual(rec["panel"][0]["rid"], "grader-a")
self.assertEqual(rec["artifacts"], {"extraction": "extraction.json"})
self.assertEqual(rec["schema_version"], 1)
self.assertIn("summary", rec["narrative"])
for key in ("composite", "quant", "qual", "penalties", "kpi_results",
"forecast_results"):
self.assertIn(key, rec)
# the record must be JSON-serializable as produced
json.dumps(rec)
if __name__ == "__main__":
unittest.main()