diff --git a/grant-compute-budget-guard/package.json b/grant-compute-budget-guard/package.json new file mode 100644 index 00000000..f9bde8f4 --- /dev/null +++ b/grant-compute-budget-guard/package.json @@ -0,0 +1,10 @@ +{ + "name": "scibase-grant-compute-budget-guard", + "version": "1.0.0", + "type": "module", + "private": true, + "scripts": { + "test": "node --test test/*.test.js", + "demo": "node scripts/demo.js" + } +} diff --git a/grant-compute-budget-guard/readme.md b/grant-compute-budget-guard/readme.md new file mode 100644 index 00000000..62020d1b --- /dev/null +++ b/grant-compute-budget-guard/readme.md @@ -0,0 +1,22 @@ +# Grant Compute Budget Guard + +This module contributes to SCIBASE issue #20, Revenue Infrastructure. + +It evaluates AI compute usage before the platform converts usage into billable spend. The guard checks grant award validity, billing holds, usage budgets, institutional invoice requirements, and high-margin overage rules so research teams do not accidentally create unrecoverable compute charges. + +## Local Verification + +```bash +npm test +npm run demo +``` + +## Demo Evidence + +The demo transcript is captured in `reports/demo-transcript.md`. The demo generates these reviewer artifacts: + +- `reports/compute-budget-report.md` +- `reports/compute-budget-packet.json` +- `reports/summary.svg` + +The demo data is synthetic and does not call payment processors, cloud providers, or grant systems. diff --git a/grant-compute-budget-guard/reports/compute-budget-packet.json b/grant-compute-budget-guard/reports/compute-budget-packet.json new file mode 100644 index 00000000..1878c279 --- /dev/null +++ b/grant-compute-budget-guard/reports/compute-budget-packet.json @@ -0,0 +1,100 @@ +{ + "title": "SCIBASE Grant Compute Budget Guard", + "issue": "SCIBASE.AI#20", + "claim": "/claim #20", + "evaluation": { + "status": "hold_billing", + "generatedAt": "2026-06-13T18:30:00.000Z", + "packetId": "scibase-grant-compute-budget-demo", + "digest": "a0876258828149a3de20dd81473f46891355c1b423bb7d3f71b81377dacf2d7a", + "counts": { + "accounts": 2, + "grants": 2, + "blockers": 6, + "warnings": 0, + "heldAccounts": 1 + }, + "blockers": [ + { + "code": "missing_award_id", + "accountId": "acct-lab-hold", + "message": "Grant-backed account is missing an award id." + }, + { + "code": "expired_grant", + "accountId": "acct-lab-hold", + "message": "Grant has expired before pending usage billing." + }, + { + "code": "grant_billing_hold", + "accountId": "acct-lab-hold", + "message": "Grant is under a billing hold." + }, + { + "code": "missing_purchase_order", + "accountId": "acct-lab-hold", + "message": "Institutional invoice account is missing a purchase order." + }, + { + "code": "budget_overage", + "accountId": "acct-lab-hold", + "message": "Projected compute spend exceeds the configured budget limit.", + "projectedSpend": 5750, + "budgetLimit": 5000, + "overagePercent": 15 + }, + { + "code": "low_compute_margin", + "accountId": "acct-lab-hold", + "message": "Pending usage falls below the minimum gross margin threshold.", + "grossMarginPercent": 20, + "minGrossMarginPercent": 30 + } + ], + "warnings": [], + "decisions": [ + { + "accountId": "acct-lab-stable", + "institution": "North Campus Lab", + "decision": "bill", + "reasons": [], + "requiredActions": [] + }, + { + "accountId": "acct-lab-hold", + "institution": "Materials Institute", + "decision": "hold", + "reasons": [ + "missing_award_id", + "expired_grant", + "grant_billing_hold", + "missing_purchase_order", + "budget_overage", + "low_compute_margin" + ], + "requiredActions": [ + "Add the grant award id before recognizing sponsored compute revenue.", + "Move usage to a renewed grant or institutional invoice before billing.", + "Resolve the sponsor billing hold before creating an invoice.", + "Attach a purchase order for institutional invoicing.", + "Require budget-owner approval before converting usage to billable spend.", + "Reprice the compute job or route it to a cheaper execution tier." + ] + } + ], + "policy": { + "maxOveragePercent": 5, + "minGrossMarginPercent": 30, + "requirePurchaseOrderForInvoice": true, + "requireGrantAwardId": true, + "blockExpiredGrants": true + } + }, + "reviewerChecklist": [ + "Grant-backed usage has an award id and active grant window.", + "Institutional invoice accounts include purchase orders.", + "Pending AI compute charges stay within approved budget limits.", + "Billing holds stop invoices before revenue recognition.", + "Compute usage preserves the configured gross margin floor." + ] +} diff --git a/grant-compute-budget-guard/reports/compute-budget-report.md b/grant-compute-budget-guard/reports/compute-budget-report.md new file mode 100644 index 00000000..4860ee3c --- /dev/null +++ b/grant-compute-budget-guard/reports/compute-budget-report.md @@ -0,0 +1,25 @@ +# Grant Compute Budget Guard Report + +Issue: SCIBASE.AI#20 +Claim marker: `/claim #20` +Status: `hold_billing` +Digest: `a0876258828149a3de20dd81473f46891355c1b423bb7d3f71b81377dacf2d7a` + +## Reviewer Checklist +- Grant-backed usage has an award id and active grant window. +- Institutional invoice accounts include purchase orders. +- Pending AI compute charges stay within approved budget limits. +- Billing holds stop invoices before revenue recognition. +- Compute usage preserves the configured gross margin floor. + +## Blockers +- missing_award_id: Grant-backed account is missing an award id. +- expired_grant: Grant has expired before pending usage billing. +- grant_billing_hold: Grant is under a billing hold. +- missing_purchase_order: Institutional invoice account is missing a purchase order. +- budget_overage: Projected compute spend exceeds the configured budget limit. +- low_compute_margin: Pending usage falls below the minimum gross margin threshold. + +## Account Decisions +- acct-lab-stable: bill +- acct-lab-hold: hold (missing_award_id, expired_grant, grant_billing_hold, missing_purchase_order, budget_overage, low_compute_margin) diff --git a/grant-compute-budget-guard/reports/demo-transcript.md b/grant-compute-budget-guard/reports/demo-transcript.md new file mode 100644 index 00000000..dcb78976 --- /dev/null +++ b/grant-compute-budget-guard/reports/demo-transcript.md @@ -0,0 +1,35 @@ +# Grant Compute Budget Guard Demo Transcript + +Date verified: 2026-06-15 + +Commands run from `grant-compute-budget-guard`: + +```bash +npm test +npm run demo +``` + +Test result: + +```text +5 tests passed +0 tests failed +``` + +Demo output: + +```json +{ + "status": "hold_billing", + "digest": "a0876258828149a3de20dd81473f46891355c1b423bb7d3f71b81377dacf2d7a", + "blockers": 6, + "heldAccounts": 1, + "reportsDir": "grant-compute-budget-guard/reports" +} +``` + +Generated reviewer artifacts: + +- `reports/compute-budget-report.md` +- `reports/compute-budget-packet.json` +- `reports/summary.svg` diff --git a/grant-compute-budget-guard/reports/summary.svg b/grant-compute-budget-guard/reports/summary.svg new file mode 100644 index 00000000..c9dc3175 --- /dev/null +++ b/grant-compute-budget-guard/reports/summary.svg @@ -0,0 +1 @@ +Grant Compute Budget GuardStatus: hold_billing | Blockers: 6Accounts inspected: 2Held accounts: 1Digest: a0876258828149a3de20dd81Synthetic billing records only. No processor, cloud, or grant-system calls. diff --git a/grant-compute-budget-guard/scripts/demo.js b/grant-compute-budget-guard/scripts/demo.js new file mode 100644 index 00000000..3773eea2 --- /dev/null +++ b/grant-compute-budget-guard/scripts/demo.js @@ -0,0 +1,29 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { + buildReviewerPacket, + demoPacket, + renderMarkdownReport, + renderSvgSummary +} from "../src/index.js"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const moduleRoot = path.resolve(__dirname, ".."); +const reportsDir = path.join(moduleRoot, "reports"); +const packet = demoPacket(); +const reviewerPacket = buildReviewerPacket(packet, { now: packet.generatedAt }); + +fs.mkdirSync(reportsDir, { recursive: true }); +fs.writeFileSync(path.join(reportsDir, "compute-budget-packet.json"), `${JSON.stringify(reviewerPacket, null, 2)}\n`); +fs.writeFileSync(path.join(reportsDir, "compute-budget-report.md"), renderMarkdownReport(packet, { now: packet.generatedAt })); +fs.writeFileSync(path.join(reportsDir, "summary.svg"), renderSvgSummary(packet, { now: packet.generatedAt })); + +console.log(JSON.stringify({ + status: reviewerPacket.evaluation.status, + digest: reviewerPacket.evaluation.digest, + blockers: reviewerPacket.evaluation.counts.blockers, + heldAccounts: reviewerPacket.evaluation.counts.heldAccounts, + reportsDir +}, null, 2)); diff --git a/grant-compute-budget-guard/src/index.js b/grant-compute-budget-guard/src/index.js new file mode 100644 index 00000000..f615f5b0 --- /dev/null +++ b/grant-compute-budget-guard/src/index.js @@ -0,0 +1,231 @@ +import crypto from "node:crypto"; + +const DEFAULT_POLICY = { + maxOveragePercent: 5, + minGrossMarginPercent: 30, + requirePurchaseOrderForInvoice: true, + requireGrantAwardId: true, + blockExpiredGrants: true +}; + +export function evaluateComputeBudgets(packet, options = {}) { + const normalized = normalizePacket(packet); + const policy = { ...DEFAULT_POLICY, ...normalized.policy, ...(options.policy ?? {}) }; + const now = new Date(options.now ?? normalized.generatedAt); + const grants = new Map(normalized.grants.map((grant) => [grant.id, grant])); + const blockers = []; + const warnings = []; + const decisions = []; + + for (const account of normalized.accounts) { + const grant = grants.get(account.grantId); + const decision = { + accountId: account.id, + institution: account.institution, + decision: "bill", + reasons: [], + requiredActions: [] + }; + const projectedSpend = account.currentSpend + account.pendingUsageCost; + const overagePercent = account.budgetLimit ? ((projectedSpend - account.budgetLimit) / account.budgetLimit) * 100 : 0; + const grossMarginPercent = account.pendingUsageCost ? ((account.pendingUsageCost - account.cloudCost) / account.pendingUsageCost) * 100 : 100; + + if (!grant) { + hold(decision, "missing_grant_record", "Attach a grant or institutional billing record before billing compute usage."); + blockers.push(finding("missing_grant_record", account.id, "Compute account references a missing grant record.")); + } else { + if (policy.requireGrantAwardId && !grant.awardId) { + hold(decision, "missing_award_id", "Add the grant award id before recognizing sponsored compute revenue."); + blockers.push(finding("missing_award_id", account.id, "Grant-backed account is missing an award id.")); + } + + if (policy.blockExpiredGrants && new Date(grant.endsAt) < now) { + hold(decision, "expired_grant", "Move usage to a renewed grant or institutional invoice before billing."); + blockers.push(finding("expired_grant", account.id, "Grant has expired before pending usage billing.")); + } + + if (grant.billingHold) { + hold(decision, "grant_billing_hold", "Resolve the sponsor billing hold before creating an invoice."); + blockers.push(finding("grant_billing_hold", account.id, "Grant is under a billing hold.")); + } + } + + if (policy.requirePurchaseOrderForInvoice && account.billingMode === "invoice" && !account.purchaseOrder) { + hold(decision, "missing_purchase_order", "Attach a purchase order for institutional invoicing."); + blockers.push(finding("missing_purchase_order", account.id, "Institutional invoice account is missing a purchase order.")); + } + + if (projectedSpend > account.budgetLimit) { + if (overagePercent > policy.maxOveragePercent) { + hold(decision, "budget_overage", "Require budget-owner approval before converting usage to billable spend."); + blockers.push({ + ...finding("budget_overage", account.id, "Projected compute spend exceeds the configured budget limit."), + projectedSpend, + budgetLimit: account.budgetLimit, + overagePercent: round(overagePercent) + }); + } else { + warnings.push({ + ...finding("minor_budget_overage", account.id, "Projected spend is slightly above the budget limit."), + projectedSpend, + budgetLimit: account.budgetLimit, + overagePercent: round(overagePercent) + }); + } + } + + if (grossMarginPercent < policy.minGrossMarginPercent) { + hold(decision, "low_compute_margin", "Reprice the compute job or route it to a cheaper execution tier."); + blockers.push({ + ...finding("low_compute_margin", account.id, "Pending usage falls below the minimum gross margin threshold."), + grossMarginPercent: round(grossMarginPercent), + minGrossMarginPercent: policy.minGrossMarginPercent + }); + } + + decisions.push(decision); + } + + const status = blockers.length ? "hold_billing" : warnings.length ? "needs_finance_review" : "ready_to_bill"; + return { + status, + generatedAt: now.toISOString(), + packetId: normalized.packetId, + digest: digest({ packetId: normalized.packetId, blockers, warnings, decisions }), + counts: { + accounts: normalized.accounts.length, + grants: normalized.grants.length, + blockers: blockers.length, + warnings: warnings.length, + heldAccounts: decisions.filter((item) => item.decision === "hold").length + }, + blockers, + warnings, + decisions, + policy + }; +} + +export function buildReviewerPacket(packet, options = {}) { + return { + title: "SCIBASE Grant Compute Budget Guard", + issue: "SCIBASE.AI#20", + claim: "/claim #20", + evaluation: evaluateComputeBudgets(packet, options), + reviewerChecklist: [ + "Grant-backed usage has an award id and active grant window.", + "Institutional invoice accounts include purchase orders.", + "Pending AI compute charges stay within approved budget limits.", + "Billing holds stop invoices before revenue recognition.", + "Compute usage preserves the configured gross margin floor." + ] + }; +} + +export function renderMarkdownReport(packet, options = {}) { + const review = buildReviewerPacket(packet, options); + const { evaluation } = review; + return [ + "# Grant Compute Budget Guard Report", + "", + `Issue: ${review.issue}`, + `Claim marker: \`${review.claim}\``, + `Status: \`${evaluation.status}\``, + `Digest: \`${evaluation.digest}\``, + "", + "## Reviewer Checklist", + ...review.reviewerChecklist.map((item) => `- ${item}`), + "", + "## Blockers", + ...(evaluation.blockers.length ? evaluation.blockers.map((item) => `- ${item.code}: ${item.message}`) : ["- None."]), + "", + "## Account Decisions", + ...evaluation.decisions.map((item) => `- ${item.accountId}: ${item.decision}${item.reasons.length ? ` (${item.reasons.join(", ")})` : ""}`) + ].join("\n") + "\n"; +} + +export function renderSvgSummary(packet, options = {}) { + const review = buildReviewerPacket(packet, options); + const { evaluation } = review; + return `Grant Compute Budget GuardStatus: ${escapeXml(evaluation.status)} | Blockers: ${evaluation.counts.blockers}Accounts inspected: ${evaluation.counts.accounts}Held accounts: ${evaluation.counts.heldAccounts}Digest: ${escapeXml(evaluation.digest.slice(0, 24))}Synthetic billing records only. No processor, cloud, or grant-system calls.\n`; +} + +export function demoPacket() { + return { + packetId: "scibase-grant-compute-budget-demo", + generatedAt: "2026-06-13T18:30:00.000Z", + grants: [ + { id: "grant-active-nsf", awardId: "NSF-2042-OPEN", sponsor: "NSF", endsAt: "2026-12-31T00:00:00.000Z", billingHold: false }, + { id: "grant-expired-doe", awardId: "", sponsor: "DOE", endsAt: "2026-04-30T00:00:00.000Z", billingHold: true } + ], + accounts: [ + { id: "acct-lab-stable", institution: "North Campus Lab", grantId: "grant-active-nsf", billingMode: "card", purchaseOrder: "", budgetLimit: 10000, currentSpend: 7200, pendingUsageCost: 1100, cloudCost: 520 }, + { id: "acct-lab-hold", institution: "Materials Institute", grantId: "grant-expired-doe", billingMode: "invoice", purchaseOrder: "", budgetLimit: 5000, currentSpend: 4800, pendingUsageCost: 950, cloudCost: 760 } + ] + }; +} + +export function normalizePacket(packet) { + if (!packet || typeof packet !== "object") throw new TypeError("A compute budget packet object is required."); + return { + packetId: text(packet.packetId, "packetId"), + generatedAt: text(packet.generatedAt, "generatedAt"), + policy: packet.policy ?? {}, + grants: asArray(packet.grants, "grants").map((grant) => ({ + id: text(grant.id, "grant.id"), + awardId: String(grant.awardId ?? "").trim(), + sponsor: text(grant.sponsor, "grant.sponsor"), + endsAt: text(grant.endsAt, "grant.endsAt"), + billingHold: Boolean(grant.billingHold) + })), + accounts: asArray(packet.accounts, "accounts").map((account) => ({ + id: text(account.id, "account.id"), + institution: text(account.institution, "account.institution"), + grantId: text(account.grantId, "account.grantId"), + billingMode: text(account.billingMode, "account.billingMode"), + purchaseOrder: String(account.purchaseOrder ?? "").trim(), + budgetLimit: number(account.budgetLimit, "account.budgetLimit"), + currentSpend: number(account.currentSpend, "account.currentSpend"), + pendingUsageCost: number(account.pendingUsageCost, "account.pendingUsageCost"), + cloudCost: number(account.cloudCost, "account.cloudCost") + })) + }; +} + +function hold(decision, reason, requiredAction) { + decision.decision = "hold"; + decision.reasons.push(reason); + decision.requiredActions.push(requiredAction); +} + +function finding(code, accountId, message) { + return { code, accountId, message }; +} + +function text(value, name) { + if (typeof value !== "string" || !value.trim()) throw new TypeError(`${name} must be a non-empty string.`); + return value.trim(); +} + +function number(value, name) { + const parsed = Number(value); + if (!Number.isFinite(parsed)) throw new TypeError(`${name} must be finite.`); + return parsed; +} + +function asArray(value, name) { + if (!Array.isArray(value)) throw new TypeError(`${name} must be an array.`); + return value; +} + +function digest(value) { + return crypto.createHash("sha256").update(JSON.stringify(value)).digest("hex"); +} + +function round(value) { + return Math.round(value * 100) / 100; +} + +function escapeXml(value) { + return String(value).replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll("\"", """); +} diff --git a/grant-compute-budget-guard/test/index.test.js b/grant-compute-budget-guard/test/index.test.js new file mode 100644 index 00000000..894a3e7a --- /dev/null +++ b/grant-compute-budget-guard/test/index.test.js @@ -0,0 +1,69 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + buildReviewerPacket, + demoPacket, + evaluateComputeBudgets, + normalizePacket, + renderMarkdownReport, + renderSvgSummary +} from "../src/index.js"; + +test("holds expired grant, billing hold, and missing purchase order", () => { + const result = evaluateComputeBudgets(demoPacket(), { now: "2026-06-13T18:30:00.000Z" }); + assert.equal(result.status, "hold_billing"); + assert.ok(result.blockers.some((blocker) => blocker.code === "missing_award_id")); + assert.ok(result.blockers.some((blocker) => blocker.code === "expired_grant")); + assert.ok(result.blockers.some((blocker) => blocker.code === "grant_billing_hold")); + assert.ok(result.blockers.some((blocker) => blocker.code === "missing_purchase_order")); +}); + +test("holds large overages and low margin usage", () => { + const result = evaluateComputeBudgets(demoPacket(), { now: "2026-06-13T18:30:00.000Z" }); + assert.ok(result.blockers.some((blocker) => blocker.code === "budget_overage")); + assert.ok(result.blockers.some((blocker) => blocker.code === "low_compute_margin")); +}); + +test("releases clean compute billing packets", () => { + const packet = demoPacket(); + packet.accounts = [packet.accounts[0]]; + const result = evaluateComputeBudgets(packet, { now: "2026-06-13T18:30:00.000Z" }); + assert.equal(result.status, "ready_to_bill"); + assert.equal(result.counts.blockers, 0); + assert.equal(result.counts.heldAccounts, 0); +}); + +test("warns on small budget overages", () => { + const packet = demoPacket(); + packet.accounts = [{ + id: "acct-small-overage", + institution: "Small Lab", + grantId: "grant-active-nsf", + billingMode: "card", + purchaseOrder: "", + budgetLimit: 1000, + currentSpend: 980, + pendingUsageCost: 40, + cloudCost: 10 + }]; + const result = evaluateComputeBudgets(packet, { now: "2026-06-13T18:30:00.000Z" }); + assert.equal(result.status, "needs_finance_review"); + assert.ok(result.warnings.some((warning) => warning.code === "minor_budget_overage")); +}); + +test("rejects malformed packets and renders claim artifacts", () => { + assert.equal(normalizePacket(demoPacket()).accounts.length, 2); + const broken = demoPacket(); + broken.accounts[0].budgetLimit = "not-a-number"; + assert.throws(() => normalizePacket(broken), /budgetLimit must be finite/); + + const packet = demoPacket(); + const review = buildReviewerPacket(packet, { now: packet.generatedAt }); + const markdown = renderMarkdownReport(packet, { now: packet.generatedAt }); + const svg = renderSvgSummary(packet, { now: packet.generatedAt }); + assert.equal(review.claim, "/claim #20"); + assert.match(markdown, /Grant Compute Budget Guard Report/); + assert.match(markdown, /`\/claim #20`/); + assert.match(svg, /