@blamejs/core 0.12.27 → 0.12.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +4 -0
- package/README.md +2 -0
- package/index.js +2 -0
- package/lib/ai-capability.js +482 -0
- package/lib/ai-dp.js +539 -0
- package/lib/crypto.js +9 -2
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.12.x
|
|
10
10
|
|
|
11
|
+
- v0.12.29 (2026-05-24) — **`b.ai.dp` — float-safe differential privacy: snapping-mechanism Laplace + discrete Gaussian + Rényi-DP budgets.** Differential privacy adds calibrated noise so an aggregate is provably insensitive to any single record — but the guarantee is fragile: Mironov (2012) showed that a Laplace mechanism sampled with naive double-precision floats lets an attacker distinguish neighbouring datasets with > 35% probability from a single output, silently destroying the promise. `b.ai.dp` ships only mechanisms whose sampling is hardened against that attack class: Laplace via the snapping mechanism (clamp + CSPRNG sign + full-mantissa uniform + power-of-two-grid rounding) and the discrete Gaussian (Canonne–Kamath–Steinke 2020) via integer-exact rejection sampling built from Bernoulli(exp(−γ)) over exact rationals — no floating-point noise at all. All randomness comes from `b.crypto.generateBytes` (SHAKE256 over the OS CSPRNG), never `Math.random`. `b.ai.dp.budget({ scope, epsilon, delta })` tracks a privacy budget per scope and refuses a `consume` that would exceed it, accounting composition either by basic summation (default) or a Rényi-DP accountant (Mironov 2017) for a much tighter bound under repeated Gaussian releases. NIST SP 800-226 (2025) is the evaluation standard; Dwork & Roth is the canonical reference. The exponential and sparse-vector mechanisms are deferred-with-condition — their float-safe constructions (base-2 / permute-and-flip; snapped SVT) re-open on operator demand, since shipping them float-unsafe would defeat the module's purpose. **Added:** *`b.ai.dp.mechanism({ type, sensitivity, epsilon, ... })` — float-safe noise mechanisms* — `type: "laplace"` is the snapping mechanism (pure ε-DP, real-valued, requires a clamp `bound` the guarantee depends on); `type: "gaussian"` is the discrete Gaussian (integer-valued, (ε, δ)-DP, requires `delta`). The Gaussian uses the classic calibration σ = √(2 ln(1.25/δ))·Δ/ε, proven for ε ≤ 1 — larger ε is refused with a pointer to splitting the release under an rdp budget. Descriptors are validated + frozen at construction so a malformed parameter fails fast. · *`b.ai.dp.budget({ scope, epsilon, delta, accounting })` — per-scope privacy budget* — Returns `{ consume, remaining, spent, reset }`. `consume(mechanism, value)` adds the mechanism's noise, charges the accountant, and throws `aiDp/budget-exhausted` if the release would push the scope past its (ε, δ). `accounting: "basic"` (default) sums per-release ε and δ; `accounting: "rdp"` runs a Rényi-DP accountant across a grid of orders and converts to (ε, δ) at the scope's δ for a tight composition bound under repeated Gaussian releases (requires `delta > 0`). The scope budget is enforced on both ε and δ independently. **Security:** *`b.crypto.generateBytes` uniformity fix at 1-byte length* — Node's SHAKE256 XOF is non-uniform at `outputLength: 1` — the byte values 0x00 and 0xff never occur and the low bit skews to ~0.54. `b.crypto.generateBytes(1)` (and the underlying `random(1)`) now draws at least 2 bytes and slices, so a single-byte CSPRNG request is uniform. Surfaced by `b.ai.dp` per-byte noise sampling; any per-byte consumer of `generateBytes` inherits the fix. A regression test asserts 0x00 / 0xff occur and the low bit is balanced.
|
|
12
|
+
|
|
13
|
+
- v0.12.28 (2026-05-24) — **`b.ai.capability` — model-capability registry + cheapest-satisfying-model router.** `b.ai.capability.create({ models })` turns a fleet of AI model descriptors into a routing decision: given a set of requirements (context window, input/output modalities, tool use, structured output, reasoning tier, citation support, prompt-caching size), it picks the cheapest model that satisfies all of them. NIST AI RMF (AI 100-1) MAP 2.x requires documenting each model's capabilities and limitations; the Model Cards convention (Mitchell et al., 2019) formalizes that descriptor — this primitive makes the descriptor actionable. Routing to the cheapest sufficient model is a front-line defense against over-provisioning spend and composes directly with `b.ai.quota`'s `cost-usd` dimension (the chosen descriptor's rate feeds the budget charge); refusing to route a request to a model that cannot satisfy it (missing modality, too-small context window, no tool use) catches a capability mismatch before the inference call burns tokens on a guaranteed-bad result. Cost ranking uses a supplied `costBasis` (`{ inputTokens, outputTokens }`) for real per-call spend, else the sum of the per-1k rates; ties break by model id so the choice is deterministic across calls and nodes. **Added:** *`b.ai.capability.create({ models })` — capability registry + router* — Returns `{ describe, list, register, satisfies, route }`. A descriptor carries `maxContextTokens`, `maxOutputTokens`, `modalitiesIn` / `modalitiesOut` (arrays), `toolUse`, `structuredOutput`, `fineTunable`, `reasoningTier` (`none` / `basic` / `standard` / `advanced`, ordered), `citationSupport`, `promptCachingMaxTokens`, and the cost rates `costPer1kInputTokens` / `costPer1kOutputTokens`. Descriptors are validated + frozen at registration so a typo (negative cost, unknown reasoning tier, non-array modality list) surfaces at config time rather than as a silent mis-route. `describe(modelId)` returns the frozen descriptor; `register(modelId, descriptor)` adds or replaces one at runtime. · *`route({ requirements, fallback?, costBasis? })` — cheapest-satisfying selection* — Collects every model whose descriptor satisfies all requirements, then returns the cheapest (`{ modelId, descriptor, estimatedCost, reason }`). Requirements: `minContextTokens`, `minOutputTokens`, `modalitiesIn` / `modalitiesOut` (model must support every listed modality), `toolUse`, `structuredOutput`, `fineTunable`, `minReasoningTier` (tier ordering — `standard` is met by `standard` or `advanced`), `citationSupport`, `minPromptCachingTokens`. When no model matches, `fallback` (a registered model id) is returned with `reason: "fallback"`, or the call refuses with `aiCapability/no-candidate` if no fallback was supplied. Routing decisions emit `ai/capability-routed` / `ai/capability-fallback` / `ai/capability-no-candidate` through the drop-silent audit chain. · *`satisfies(modelId, requirements)` — precise capability-mismatch reasons* — Returns `{ ok, failures }` where each failure names the `requirement`, the `need`, and what the model `have`s — so a caller surfaces a precise reason (e.g. `minReasoningTier need advanced have basic`) instead of a bare boolean. Use it to explain a routing miss or to gate a request against a specific model before calling it.
|
|
14
|
+
|
|
11
15
|
- v0.12.27 (2026-05-24) — **`b.ai.quota` — per-tenant, per-model AI usage budgets with atomic consume-and-check.** `b.ai.quota.create(opts)` builds an enforcer that caps AI inference usage per `(tenant, model, dimension, period)` and defends OWASP LLM Top 10 2025 LLM10 (Unbounded Consumption) — the class that includes denial-of-wallet, where an attacker drives a high volume of pay-per-use inferences until the bill itself is the attack. Meter by `tokens`, `requests`, `cost-usd`, or `compute-hours` over a calendar-aligned UTC window (`second` through `month`). `consume(tenant, model, amount)` is a single atomic check-and-charge: under the default `hard` enforcement it reserves the amount only if it fits under the ceiling, otherwise it refuses without charging — the limit test and the charge are one indivisible operation, so there is no charge-then-refund window for a concurrent call to observe. The in-memory counter is per-process; multi-node deployments supply an `opts.store` adapter whose `reserve` (an atomic conditional test-and-charge — a Redis Lua script, a SQL `UPDATE ... WHERE used + :amt <= :limit RETURNING used`) and `add` are atomic on the shared backend to enforce one aggregate ceiling across the cluster without false denials under contention. Limit resolution is most-specific-first: `perTenantModel` over `perTenant` over `perModel` over the default `limit`; tenant and model identifiers are percent-encoded into the counter key so a hostile tenant name cannot collide with another tenant's budget. **Added:** *`b.ai.quota.create(opts)` — per-tenant AI usage-budget enforcer* — Returns `{ consume, check, snapshot, reset }` scoped to one `dimension` (`tokens` / `requests` / `cost-usd` / `compute-hours`) and one `period` (`second` / `minute` / `hour` / `day` / `week` (Monday-aligned) / `month` (1st-of-month), all UTC-aligned). `consume(tenant, model, amount, opts?)` returns `{ used, limit, remaining, allowed, exceeded, windowStart, resetsAt, ... }`. `check(tenant, model)` is the read-only snapshot. Spin up one enforcer per dimension you meter — a monthly `cost-usd` budget and a per-minute `tokens` burst cap coexist as two `create()` calls sharing one store. Defends OWASP LLM10:2025 Unbounded Consumption / denial-of-wallet; maps to NIST AI RMF (AI 100-1) MANAGE 2.x and EU AI Act Art. 15 (robustness / resource-exhaustion resilience). · *`hard` / `soft` / `warn` enforcement* — `hard` (default) refuses the over-budget call and throws `aiQuota/exceeded` without charging — the rejected reservation is refunded so the counter is untouched. `soft` admits the charge but reports `allowed: false` so the caller decides whether to honor it. `warn` admits and allows (advisory), flagging `exceeded: true`. A per-call `consume(..., { enforcement })` override lets one endpoint soften the mode for a trusted internal caller without a second enforcer. Every over-budget event emits `ai/quota-exceeded` through the drop-silent audit chain (`ai/quota-applied` on success), tagged with the active cluster node id for attribution. · *Cross-node aggregate budgets via `opts.store`* — The default counter is in-memory (per-process). Supply `opts.store` exposing atomic `reserve` / `add` / `get` / `reset` (a Redis Lua script, a shared SQL row) and the ceiling is enforced on the cluster-wide aggregate. `hard` mode goes through `reserve`, an atomic conditional test-and-charge that adds the amount only if it fits — so a concurrent over-budget call cannot transiently inflate the counter and falsely deny a smaller call that should fit. Per-tenant and per-model limit overrides (`perTenant` / `perModel` / `perTenantModel`) are validated at config time so a malformed cap surfaces at boot, not as a silent fall-through to the default.
|
|
12
16
|
|
|
13
17
|
- v0.12.26 (2026-05-24) — **`b.compliance` posture cascades — `eu-ai-act` + `ca-ab-853` + `cac-genai-label` POSTURE_DEFAULTS + backup encryption refusal.** Three new posture cascades wired into `b.compliance.POSTURE_DEFAULTS` + `KNOWN_POSTURES` + `REGIME_MAP` so operators globally pinning the EU AI Act / California AB-853 / China CAC GenAI postures get the right floors automatically: backupEncryptionRequired:true, auditChainSignedRequired:true, tlsMinVersion:TLSv1.3, requireVacuumAfterErase:true. `b.backup.bundleAdapterStorage` extends the encryption-required posture list to include the three new postures so `cryptoStrategy: "none"` is refused upfront under any of them (parity with HIPAA + PCI-DSS, which the operator surface has carried since v0.12.10). The canonical `eu-ai-act` posture is the production name; the legacy `ai-act` short name stays in KNOWN_POSTURES for back-compat with operators who pinned it pre-v0.12.26. **Added:** *`eu-ai-act` posture cascade — Regulation (EU) 2024/1689* — POSTURE_DEFAULTS entry: backupEncryptionRequired:true (Art. 12 logging + Art. 15 robustness/cybersecurity demand encryption-at-rest for high-risk system training logs), auditChainSignedRequired:true (Art. 12 + Art. 13 audit-chain integrity), tlsMinVersion:TLSv1.3, requireVacuumAfterErase:true (Art. 50(4) synthetic-content provenance — residual EXIF / metadata pointing at the generating model must be cleared on erase). REGIME_MAP entry under jurisdiction:"EU" domain:"ai-governance". KNOWN_POSTURES carries both `eu-ai-act` (canonical) and `ai-act` (legacy short name). · *`ca-ab-853` posture cascade — California AB-853 effective 2026* — Same encryption + audit floor as eu-ai-act; jurisdiction:"US-CA". Model-generated content watermarking + disclosure regime. Operators serving California traffic pin this posture for the AB-853 §22949.91 obligations the v0.12.12 deepfake primitive's crossWalk references. · *`cac-genai-label` posture cascade — China CAC GenAI Service Measures* — Synthetic-content labelling per Art. 12 + algorithm filing per Art. 4. Same backup encryption + signed audit chain floor. Operators serving Chinese traffic pin this posture so the bundleAdapterStorage refuses plaintext bundles and the disclosure primitive's `jurisdiction: "cn"` cross-walk produces the right legal-reference array. · *`bundleAdapterStorage` BACKUP_ENCRYPTION_REQUIRED_POSTURES extended* — `hipaa` + `pci-dss` (the v0.12.10 baseline) joined by the three AI postures. `cryptoStrategy: "none"` refused upfront under any of `eu-ai-act` / `ca-ab-853` / `cac-genai-label` with `backup/posture-requires-encryption`. Operators wiring backup storage in a regulated AI deployment now get the same posture-driven gate that the storage primitive has always applied to health + payment data.
|
package/README.md
CHANGED
|
@@ -163,6 +163,7 @@ The framework bundles the surface a typical Node app reaches for. Every primitiv
|
|
|
163
163
|
- **Agent identity** — A2A signed agent-card primitive (Linux Foundation Agentic AI Foundation v1.x, ML-DSA-87) (`b.a2a`)
|
|
164
164
|
- **Content provenance** — C2PA 2.1 + California SB-942 / AB-853 manifest builder for AI-generated media (provider, model id + version, timestamp, content ID, signed) (`b.contentCredentials`)
|
|
165
165
|
- **AI usage quotas** — per-tenant / per-model budgets metered by tokens / requests / cost-usd / compute-hours over calendar-aligned windows, with an atomic conditional reserve (no charge-then-refund race) + hard/soft/warn enforcement and an optional cross-node store; defends OWASP LLM10:2025 unbounded consumption / denial-of-wallet (`b.ai.quota`)
|
|
166
|
+
- **AI capability routing** — model-capability registry (context window / modalities / tool use / reasoning tier / cost rates) + a router that picks the cheapest model satisfying a request's requirements, refusing capability mismatches before the inference call (NIST AI RMF MAP + Model Cards); composes with `b.ai.quota` cost budgets (`b.ai.capability`)
|
|
166
167
|
### Compliance regimes
|
|
167
168
|
|
|
168
169
|
- **Posture coordinator** — `b.compliance` cascades operator-declared regime into retention / audit / db / cryptoField via POSTURE_DEFAULTS:
|
|
@@ -181,6 +182,7 @@ The framework bundles the surface a typical Node app reaches for. Every primitiv
|
|
|
181
182
|
- **Audit + segregation** — 21 CFR Part 11 §11.10(e) audit-content gate + §11.50(b) electronicSignature (`b.fda21cfr11`); PCI DSS 4.0 Req 10.4.1.1 daily-review automation (`b.auditDailyReview`); SOX §404 + SOC 2 CC1.3 segregation-of-duties via Postgres trigger DDL (`b.audit.bindActor`, `b.audit.assertSegregation`)
|
|
182
183
|
- **Change control + WORM** — m-of-n approver DDL change-control with maintenance-window + ML-DSA-87 signed proposals (`b.ddlChangeControl`); row-level WORM triggers boot-asserted under `sec-17a-4` / `finra-4511` / `fda-21cfr11` (`b.db.declareWorm`); dual-control physical delete + crypto-erase + REINDEX in one transaction (`b.db.declareRequireDualControl`, `b.db.eraseHard`)
|
|
183
184
|
- **Consumer-protection** — FTC click-to-cancel UX-parity attestation (`ftc-2024` / `ca-sb942` / `strict`) (`b.darkPatterns`)
|
|
185
|
+
- **Differential privacy** — float-safe DP for aggregate releases: snapping-mechanism Laplace (Mironov 2012) + discrete Gaussian (Canonne–Kamath–Steinke 2020), CSPRNG noise, per-scope ε/δ budgets with basic + Rényi-DP accounting; defends the floating-point distinguishing attack that breaks naive Laplace samplers (NIST SP 800-226) (`b.ai.dp`)
|
|
184
186
|
- **Privacy / DSR** — GDPR Articles 15–22 / CCPA / CPRA / LGPD / PIPEDA data-subject-rights workflow (`b.dsr`); IAB TCF v2.3 consent-string parser + `disclosedVendors` validator (`b.iabTcf`); IAB MSPA / GPP universal-opt-out (USNAT / USCA / USVA / USCO / USCT / USUT) + GPC mirror (`b.iabMspa`); generic consent capture + withdrawal (`b.consent`)
|
|
185
187
|
- **Incident reporters** — EU DORA Article 17 ICT-incident workflow per Commission Delegated Regulation 2024/1772 (`b.dora`); EU NIS2 (`b.nis2`); EU Cyber Resilience Act SBOM + secure-software-attestation (`b.cra`); SEC Form 8-K Item 1.05 cybersecurity-incident materiality-disclosure (`b.secCyber`); incident lifecycle coordinator (`b.incident`)
|
|
186
188
|
- **Outbound DLP** — interceptor-installed on httpClient + mail + webhook with built-in detectors for PAN (Luhn), SSN, EIN, IBAN (mod-97), api-key shapes, PEM, SSH private keys, JWTs, AWS access keys, PHI composite; refuse / redact / audit-only verdicts under pci-dss / hipaa / fapi2 / soc2 / gdpr presets (`b.redact.installOutboundDlp`)
|
package/index.js
CHANGED
|
@@ -444,6 +444,8 @@ module.exports = {
|
|
|
444
444
|
modelManifest: require("./lib/ai-model-manifest"),
|
|
445
445
|
disclosure: require("./lib/ai-disclosure"),
|
|
446
446
|
quota: require("./lib/ai-quota"),
|
|
447
|
+
capability: require("./lib/ai-capability"),
|
|
448
|
+
dp: require("./lib/ai-dp"),
|
|
447
449
|
},
|
|
448
450
|
promisePool: require("./lib/promise-pool"),
|
|
449
451
|
sdNotify: require("./lib/sd-notify"),
|
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module b.ai.capability
|
|
4
|
+
* @nav AI
|
|
5
|
+
* @title AI capability routing
|
|
6
|
+
*
|
|
7
|
+
* @intro
|
|
8
|
+
* A capability registry + capability-aware router for AI model
|
|
9
|
+
* fleets. NIST AI RMF (AI 100-1) MAP 2.x requires documenting each
|
|
10
|
+
* model's capabilities and limitations; the Model Cards convention
|
|
11
|
+
* (Mitchell et al., 2019) formalizes that descriptor. This module
|
|
12
|
+
* turns those descriptors into a routing decision: given a set of
|
|
13
|
+
* requirements (context window, modalities, tool use, reasoning
|
|
14
|
+
* tier, …), pick the <em>cheapest</em> model in the fleet that
|
|
15
|
+
* satisfies all of them, or fall back deterministically.
|
|
16
|
+
*
|
|
17
|
+
* <code>b.ai.capability.create({ models })</code> builds a registry
|
|
18
|
+
* from operator-supplied descriptors and returns:
|
|
19
|
+
*
|
|
20
|
+
* - <code>describe(modelId)</code> — the frozen descriptor.
|
|
21
|
+
* - <code>list()</code> — every registered model id.
|
|
22
|
+
* - <code>register(modelId, descriptor)</code> — add / replace one.
|
|
23
|
+
* - <code>satisfies(modelId, requirements)</code> —
|
|
24
|
+
* <code>{ ok, failures }</code> where each failure names the
|
|
25
|
+
* requirement, the need, and what the model has.
|
|
26
|
+
* - <code>route({ requirements, fallback?, costBasis? })</code> —
|
|
27
|
+
* the cheapest satisfying model, or the fallback, or a refusal.
|
|
28
|
+
*
|
|
29
|
+
* A descriptor carries: <code>maxContextTokens</code>,
|
|
30
|
+
* <code>maxOutputTokens</code>, <code>modalitiesIn</code> /
|
|
31
|
+
* <code>modalitiesOut</code> (arrays — e.g. <code>"text"</code>,
|
|
32
|
+
* <code>"image"</code>, <code>"audio"</code>, <code>"video"</code>),
|
|
33
|
+
* <code>toolUse</code>, <code>structuredOutput</code>,
|
|
34
|
+
* <code>fineTunable</code>, <code>reasoningTier</code>
|
|
35
|
+
* (<code>"none" | "basic" | "standard" | "advanced"</code>,
|
|
36
|
+
* ordered), <code>citationSupport</code>,
|
|
37
|
+
* <code>promptCachingMaxTokens</code>, and the cost rates
|
|
38
|
+
* <code>costPer1kInputTokens</code> / <code>costPer1kOutputTokens</code>.
|
|
39
|
+
*
|
|
40
|
+
* <strong>Routing picks the cheapest match.</strong> When a
|
|
41
|
+
* <code>costBasis</code> (<code>{ inputTokens, outputTokens }</code>)
|
|
42
|
+
* is supplied the router estimates the per-call cost and ranks by
|
|
43
|
+
* it; otherwise it ranks by the sum of the per-1k rates. Ties break
|
|
44
|
+
* by model id so the choice is deterministic. Routing to the
|
|
45
|
+
* cheapest sufficient model is the front-line defense against
|
|
46
|
+
* over-provisioning spend — it composes with
|
|
47
|
+
* <code>b.ai.quota</code>'s <code>cost-usd</code> dimension, where
|
|
48
|
+
* the chosen descriptor's rate feeds the budget charge.
|
|
49
|
+
*
|
|
50
|
+
* Refusing to route a request to a model that cannot satisfy it
|
|
51
|
+
* (missing modality, too-small context window, no tool use) catches
|
|
52
|
+
* a capability mismatch before the inference call burns tokens on a
|
|
53
|
+
* guaranteed-bad result.
|
|
54
|
+
*
|
|
55
|
+
* @card
|
|
56
|
+
* Capability registry + cheapest-satisfying-model router for AI
|
|
57
|
+
* model fleets (context / modalities / tool use / reasoning tier /
|
|
58
|
+
* cost). Composes with b.ai.quota cost budgets.
|
|
59
|
+
*/
|
|
60
|
+
|
|
61
|
+
var lazyRequire = require("./lazy-require");
|
|
62
|
+
var validateOpts = require("./validate-opts");
|
|
63
|
+
var { defineClass } = require("./framework-error");
|
|
64
|
+
|
|
65
|
+
var AiCapabilityError = defineClass("AiCapabilityError", { alwaysPermanent: true });
|
|
66
|
+
|
|
67
|
+
var audit = lazyRequire(function () { return require("./audit"); });
|
|
68
|
+
|
|
69
|
+
// Ordered reasoning tiers — a requirement of `minReasoningTier:
|
|
70
|
+
// "standard"` is satisfied by "standard" or "advanced", not "basic".
|
|
71
|
+
var REASONING_TIERS = ["none", "basic", "standard", "advanced"];
|
|
72
|
+
|
|
73
|
+
// Cost rates are quoted per 1000 tokens (industry convention; the
|
|
74
|
+
// descriptor fields are costPer1kInputTokens / costPer1kOutputTokens).
|
|
75
|
+
// Dividing a token count by this rate unit converts a per-1k rate into
|
|
76
|
+
// the per-token multiplier — a rate denominator, not a byte size.
|
|
77
|
+
var COST_RATE_TOKEN_UNIT = 1000; // allow:raw-byte-literal — per-1k-token cost-rate denominator, not a byte count
|
|
78
|
+
|
|
79
|
+
var DESCRIPTOR_KEYS = [
|
|
80
|
+
"maxContextTokens", "maxOutputTokens", "modalitiesIn", "modalitiesOut",
|
|
81
|
+
"toolUse", "structuredOutput", "fineTunable", "reasoningTier",
|
|
82
|
+
"citationSupport", "promptCachingMaxTokens",
|
|
83
|
+
"costPer1kInputTokens", "costPer1kOutputTokens", "provider", "version",
|
|
84
|
+
];
|
|
85
|
+
|
|
86
|
+
var REQUIREMENT_KEYS = [
|
|
87
|
+
"minContextTokens", "minOutputTokens", "modalitiesIn", "modalitiesOut",
|
|
88
|
+
"toolUse", "structuredOutput", "fineTunable", "minReasoningTier",
|
|
89
|
+
"citationSupport", "minPromptCachingTokens",
|
|
90
|
+
];
|
|
91
|
+
|
|
92
|
+
function _isPositiveInt(n) {
|
|
93
|
+
return typeof n === "number" && isFinite(n) && n > 0 && Math.floor(n) === n;
|
|
94
|
+
}
|
|
95
|
+
function _isNonNegFinite(n) {
|
|
96
|
+
return typeof n === "number" && isFinite(n) && n >= 0;
|
|
97
|
+
}
|
|
98
|
+
function _isStringArray(a) {
|
|
99
|
+
if (!Array.isArray(a)) return false;
|
|
100
|
+
for (var i = 0; i < a.length; i++) {
|
|
101
|
+
if (typeof a[i] !== "string" || a[i].length === 0) return false;
|
|
102
|
+
}
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Normalize + validate one descriptor at registration time so a typo
|
|
107
|
+
// (negative cost, unknown reasoning tier, non-array modality list)
|
|
108
|
+
// surfaces at config time rather than as a silent mis-route.
|
|
109
|
+
function _normalizeDescriptor(modelId, d) {
|
|
110
|
+
if (!d || typeof d !== "object" || Array.isArray(d)) {
|
|
111
|
+
throw new AiCapabilityError("aiCapability/bad-descriptor",
|
|
112
|
+
"ai.capability: descriptor for '" + modelId + "' must be a plain object");
|
|
113
|
+
}
|
|
114
|
+
validateOpts(d, DESCRIPTOR_KEYS, "ai.capability descriptor['" + modelId + "']");
|
|
115
|
+
|
|
116
|
+
if (!_isPositiveInt(d.maxContextTokens)) {
|
|
117
|
+
throw new AiCapabilityError("aiCapability/bad-descriptor",
|
|
118
|
+
"ai.capability: '" + modelId + "'.maxContextTokens must be a positive integer");
|
|
119
|
+
}
|
|
120
|
+
var maxOut = (d.maxOutputTokens == null) ? d.maxContextTokens : d.maxOutputTokens;
|
|
121
|
+
if (!_isPositiveInt(maxOut)) {
|
|
122
|
+
throw new AiCapabilityError("aiCapability/bad-descriptor",
|
|
123
|
+
"ai.capability: '" + modelId + "'.maxOutputTokens must be a positive integer");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
var modIn = (d.modalitiesIn == null) ? ["text"] : d.modalitiesIn;
|
|
127
|
+
var modOut = (d.modalitiesOut == null) ? ["text"] : d.modalitiesOut;
|
|
128
|
+
if (!_isStringArray(modIn) || !_isStringArray(modOut)) {
|
|
129
|
+
throw new AiCapabilityError("aiCapability/bad-descriptor",
|
|
130
|
+
"ai.capability: '" + modelId + "'.modalitiesIn / modalitiesOut must be arrays of non-empty strings");
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
var tier = (d.reasoningTier == null) ? "standard" : d.reasoningTier;
|
|
134
|
+
if (REASONING_TIERS.indexOf(tier) === -1) {
|
|
135
|
+
throw new AiCapabilityError("aiCapability/bad-descriptor",
|
|
136
|
+
"ai.capability: '" + modelId + "'.reasoningTier must be one of " + REASONING_TIERS.join(" / "));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
var cachingMax = (d.promptCachingMaxTokens == null) ? 0 : d.promptCachingMaxTokens;
|
|
140
|
+
var costIn = (d.costPer1kInputTokens == null) ? 0 : d.costPer1kInputTokens;
|
|
141
|
+
var costOut = (d.costPer1kOutputTokens == null) ? 0 : d.costPer1kOutputTokens;
|
|
142
|
+
if (!_isNonNegFinite(cachingMax) || !_isNonNegFinite(costIn) || !_isNonNegFinite(costOut)) {
|
|
143
|
+
throw new AiCapabilityError("aiCapability/bad-descriptor",
|
|
144
|
+
"ai.capability: '" + modelId + "'.promptCachingMaxTokens / costPer1kInputTokens / " +
|
|
145
|
+
"costPer1kOutputTokens must be non-negative finite numbers");
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return Object.freeze({
|
|
149
|
+
modelId: modelId,
|
|
150
|
+
maxContextTokens: d.maxContextTokens,
|
|
151
|
+
maxOutputTokens: maxOut,
|
|
152
|
+
modalitiesIn: Object.freeze(modIn.slice()),
|
|
153
|
+
modalitiesOut: Object.freeze(modOut.slice()),
|
|
154
|
+
toolUse: d.toolUse === true,
|
|
155
|
+
structuredOutput: d.structuredOutput === true,
|
|
156
|
+
fineTunable: d.fineTunable === true,
|
|
157
|
+
reasoningTier: tier,
|
|
158
|
+
citationSupport: d.citationSupport === true,
|
|
159
|
+
promptCachingMaxTokens: cachingMax,
|
|
160
|
+
costPer1kInputTokens: costIn,
|
|
161
|
+
costPer1kOutputTokens: costOut,
|
|
162
|
+
provider: (typeof d.provider === "string") ? d.provider : null,
|
|
163
|
+
version: (typeof d.version === "string") ? d.version : null,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* @primitive b.ai.capability.create
|
|
169
|
+
* @signature b.ai.capability.create(opts)
|
|
170
|
+
* @since 0.12.28
|
|
171
|
+
* @status stable
|
|
172
|
+
* @compliance soc2
|
|
173
|
+
* @related b.ai.quota.create, b.ai.modelManifest.build
|
|
174
|
+
*
|
|
175
|
+
* Build a capability registry + router from operator-supplied model
|
|
176
|
+
* descriptors. Returns <code>{ describe, list, register, satisfies,
|
|
177
|
+
* route }</code>. Pair it with <code>b.ai.quota</code>:
|
|
178
|
+
* <code>route()</code> picks the cheapest model that meets the
|
|
179
|
+
* request, and the chosen descriptor's cost rate feeds the
|
|
180
|
+
* <code>cost-usd</code> budget charge.
|
|
181
|
+
*
|
|
182
|
+
* @opts
|
|
183
|
+
* {
|
|
184
|
+
* models: { // required, ≥ 1 entry
|
|
185
|
+
* [modelId: string]: {
|
|
186
|
+
* maxContextTokens: number, // required, positive int
|
|
187
|
+
* maxOutputTokens?: number, // default: maxContextTokens
|
|
188
|
+
* modalitiesIn?: string[], // default: ["text"]
|
|
189
|
+
* modalitiesOut?: string[], // default: ["text"]
|
|
190
|
+
* toolUse?: boolean, // default: false
|
|
191
|
+
* structuredOutput?: boolean, // default: false
|
|
192
|
+
* fineTunable?: boolean, // default: false
|
|
193
|
+
* reasoningTier?: string, // none|basic|standard|advanced
|
|
194
|
+
* citationSupport?: boolean, // default: false
|
|
195
|
+
* promptCachingMaxTokens?: number, // default: 0
|
|
196
|
+
* costPer1kInputTokens?: number, // default: 0
|
|
197
|
+
* costPer1kOutputTokens?: number, // default: 0
|
|
198
|
+
* provider?: string,
|
|
199
|
+
* version?: string,
|
|
200
|
+
* }
|
|
201
|
+
* },
|
|
202
|
+
* audit?: boolean, // default: true (route decisions)
|
|
203
|
+
* }
|
|
204
|
+
*
|
|
205
|
+
* @example
|
|
206
|
+
* var fleet = b.ai.capability.create({
|
|
207
|
+
* models: {
|
|
208
|
+
* "haiku": { maxContextTokens: 200000, reasoningTier: "basic",
|
|
209
|
+
* costPer1kInputTokens: 0.001, costPer1kOutputTokens: 0.005 },
|
|
210
|
+
* "opus": { maxContextTokens: 200000, reasoningTier: "advanced",
|
|
211
|
+
* toolUse: true, modalitiesIn: ["text", "image"],
|
|
212
|
+
* costPer1kInputTokens: 0.015, costPer1kOutputTokens: 0.075 },
|
|
213
|
+
* },
|
|
214
|
+
* });
|
|
215
|
+
* var pick = fleet.route({
|
|
216
|
+
* requirements: { minContextTokens: 100000, toolUse: true,
|
|
217
|
+
* modalitiesIn: ["text", "image"] },
|
|
218
|
+
* costBasis: { inputTokens: 4000, outputTokens: 500 },
|
|
219
|
+
* });
|
|
220
|
+
* // → { modelId: "opus", descriptor: {...}, estimatedCost: 0.0975, reason: "cheapest-of-1" }
|
|
221
|
+
*/
|
|
222
|
+
function create(opts) {
|
|
223
|
+
validateOpts.requireObject(opts, "ai.capability.create", AiCapabilityError);
|
|
224
|
+
validateOpts(opts, ["models", "audit"], "ai.capability.create");
|
|
225
|
+
|
|
226
|
+
if (!opts.models || typeof opts.models !== "object" || Array.isArray(opts.models)) {
|
|
227
|
+
throw new AiCapabilityError("aiCapability/bad-models",
|
|
228
|
+
"ai.capability.create: models must be a plain object { modelId: descriptor }");
|
|
229
|
+
}
|
|
230
|
+
var ids = Object.keys(opts.models);
|
|
231
|
+
if (ids.length === 0) {
|
|
232
|
+
throw new AiCapabilityError("aiCapability/bad-models",
|
|
233
|
+
"ai.capability.create: models must declare at least one model");
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
var registry = new Map();
|
|
237
|
+
for (var i = 0; i < ids.length; i++) {
|
|
238
|
+
registry.set(ids[i], _normalizeDescriptor(ids[i], opts.models[ids[i]]));
|
|
239
|
+
}
|
|
240
|
+
var auditOn = opts.audit !== false;
|
|
241
|
+
|
|
242
|
+
function _emitAudit(action, outcome, metadata) {
|
|
243
|
+
if (!auditOn) return;
|
|
244
|
+
try {
|
|
245
|
+
audit().safeEmit({ action: action, outcome: outcome, metadata: metadata || {} });
|
|
246
|
+
} catch (_e) { /* audit best-effort — drop-silent */ }
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function describe(modelId) {
|
|
250
|
+
var d = registry.get(modelId);
|
|
251
|
+
if (!d) {
|
|
252
|
+
throw new AiCapabilityError("aiCapability/unknown-model",
|
|
253
|
+
"ai.capability.describe: unknown model '" + modelId + "'");
|
|
254
|
+
}
|
|
255
|
+
return d;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function list() {
|
|
259
|
+
return Array.from(registry.keys());
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function register(modelId, descriptor) {
|
|
263
|
+
validateOpts.requireNonEmptyString(modelId,
|
|
264
|
+
"ai.capability.register: modelId", AiCapabilityError, "aiCapability/bad-model");
|
|
265
|
+
registry.set(modelId, _normalizeDescriptor(modelId, descriptor));
|
|
266
|
+
return registry.get(modelId);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Returns { ok, failures } — every unmet requirement names what was
|
|
270
|
+
// needed and what the model has, so a caller can surface a precise
|
|
271
|
+
// capability-mismatch reason instead of a bare boolean.
|
|
272
|
+
function _evaluate(descriptor, requirements) {
|
|
273
|
+
var failures = [];
|
|
274
|
+
function fail(requirement, need, have) {
|
|
275
|
+
failures.push({ requirement: requirement, need: need, have: have });
|
|
276
|
+
}
|
|
277
|
+
if (requirements.minContextTokens != null &&
|
|
278
|
+
descriptor.maxContextTokens < requirements.minContextTokens) {
|
|
279
|
+
fail("minContextTokens", requirements.minContextTokens, descriptor.maxContextTokens);
|
|
280
|
+
}
|
|
281
|
+
if (requirements.minOutputTokens != null &&
|
|
282
|
+
descriptor.maxOutputTokens < requirements.minOutputTokens) {
|
|
283
|
+
fail("minOutputTokens", requirements.minOutputTokens, descriptor.maxOutputTokens);
|
|
284
|
+
}
|
|
285
|
+
if (requirements.modalitiesIn != null) {
|
|
286
|
+
for (var a = 0; a < requirements.modalitiesIn.length; a++) {
|
|
287
|
+
if (descriptor.modalitiesIn.indexOf(requirements.modalitiesIn[a]) === -1) {
|
|
288
|
+
fail("modalitiesIn", requirements.modalitiesIn[a], descriptor.modalitiesIn);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
if (requirements.modalitiesOut != null) {
|
|
293
|
+
for (var b = 0; b < requirements.modalitiesOut.length; b++) {
|
|
294
|
+
if (descriptor.modalitiesOut.indexOf(requirements.modalitiesOut[b]) === -1) {
|
|
295
|
+
fail("modalitiesOut", requirements.modalitiesOut[b], descriptor.modalitiesOut);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
if (requirements.toolUse === true && descriptor.toolUse !== true) {
|
|
300
|
+
fail("toolUse", true, false);
|
|
301
|
+
}
|
|
302
|
+
if (requirements.structuredOutput === true && descriptor.structuredOutput !== true) {
|
|
303
|
+
fail("structuredOutput", true, false);
|
|
304
|
+
}
|
|
305
|
+
if (requirements.fineTunable === true && descriptor.fineTunable !== true) {
|
|
306
|
+
fail("fineTunable", true, false);
|
|
307
|
+
}
|
|
308
|
+
if (requirements.citationSupport === true && descriptor.citationSupport !== true) {
|
|
309
|
+
fail("citationSupport", true, false);
|
|
310
|
+
}
|
|
311
|
+
if (requirements.minReasoningTier != null &&
|
|
312
|
+
REASONING_TIERS.indexOf(descriptor.reasoningTier) <
|
|
313
|
+
REASONING_TIERS.indexOf(requirements.minReasoningTier)) {
|
|
314
|
+
fail("minReasoningTier", requirements.minReasoningTier, descriptor.reasoningTier);
|
|
315
|
+
}
|
|
316
|
+
if (requirements.minPromptCachingTokens != null &&
|
|
317
|
+
descriptor.promptCachingMaxTokens < requirements.minPromptCachingTokens) {
|
|
318
|
+
fail("minPromptCachingTokens", requirements.minPromptCachingTokens, descriptor.promptCachingMaxTokens);
|
|
319
|
+
}
|
|
320
|
+
return { ok: failures.length === 0, failures: failures };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function _validateRequirements(requirements) {
|
|
324
|
+
if (requirements == null) return {};
|
|
325
|
+
if (typeof requirements !== "object" || Array.isArray(requirements)) {
|
|
326
|
+
throw new AiCapabilityError("aiCapability/bad-requirements",
|
|
327
|
+
"ai.capability: requirements must be a plain object");
|
|
328
|
+
}
|
|
329
|
+
validateOpts(requirements, REQUIREMENT_KEYS, "ai.capability requirements");
|
|
330
|
+
if (requirements.minReasoningTier != null &&
|
|
331
|
+
REASONING_TIERS.indexOf(requirements.minReasoningTier) === -1) {
|
|
332
|
+
throw new AiCapabilityError("aiCapability/bad-requirements",
|
|
333
|
+
"ai.capability: minReasoningTier must be one of " + REASONING_TIERS.join(" / "));
|
|
334
|
+
}
|
|
335
|
+
if (requirements.modalitiesIn != null && !_isStringArray(requirements.modalitiesIn)) {
|
|
336
|
+
throw new AiCapabilityError("aiCapability/bad-requirements",
|
|
337
|
+
"ai.capability: requirements.modalitiesIn must be an array of non-empty strings");
|
|
338
|
+
}
|
|
339
|
+
if (requirements.modalitiesOut != null && !_isStringArray(requirements.modalitiesOut)) {
|
|
340
|
+
throw new AiCapabilityError("aiCapability/bad-requirements",
|
|
341
|
+
"ai.capability: requirements.modalitiesOut must be an array of non-empty strings");
|
|
342
|
+
}
|
|
343
|
+
// Numeric minimums are compared with `<` against the descriptor; a
|
|
344
|
+
// non-numeric value (NaN, "128k", a bad parse) makes that compare
|
|
345
|
+
// false and SILENTLY satisfies the requirement, so an undersized
|
|
346
|
+
// model could be selected. Reject non-finite / negative here so a
|
|
347
|
+
// malformed requirement fails fast instead of fail-open.
|
|
348
|
+
var numericMins = ["minContextTokens", "minOutputTokens", "minPromptCachingTokens"];
|
|
349
|
+
for (var ni = 0; ni < numericMins.length; ni++) {
|
|
350
|
+
var nk = numericMins[ni];
|
|
351
|
+
if (requirements[nk] != null && !_isNonNegFinite(requirements[nk])) {
|
|
352
|
+
throw new AiCapabilityError("aiCapability/bad-requirements",
|
|
353
|
+
"ai.capability: requirements." + nk + " must be a non-negative finite number");
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
// Boolean opt-in requirements are matched with `=== true`; a
|
|
357
|
+
// non-boolean (truthy 1, "false") would silently fail to require
|
|
358
|
+
// the capability. Reject non-booleans so the intent is explicit.
|
|
359
|
+
var booleanReqs = ["toolUse", "structuredOutput", "fineTunable", "citationSupport"];
|
|
360
|
+
for (var bi = 0; bi < booleanReqs.length; bi++) {
|
|
361
|
+
var bk = booleanReqs[bi];
|
|
362
|
+
if (requirements[bk] != null && typeof requirements[bk] !== "boolean") {
|
|
363
|
+
throw new AiCapabilityError("aiCapability/bad-requirements",
|
|
364
|
+
"ai.capability: requirements." + bk + " must be a boolean");
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
return requirements;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function satisfies(modelId, requirements) {
|
|
371
|
+
return _evaluate(describe(modelId), _validateRequirements(requirements));
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Per-call cost estimate. With a costBasis the estimate is the
|
|
375
|
+
// real per-call spend (input + output tokens at the model's rates);
|
|
376
|
+
// without one it is the sum of the per-1k rates — a stable proxy
|
|
377
|
+
// for "cheaper model" when the caller hasn't sized the request.
|
|
378
|
+
function _estimateCost(descriptor, costBasis) {
|
|
379
|
+
if (costBasis) {
|
|
380
|
+
var inTok = _isNonNegFinite(costBasis.inputTokens) ? costBasis.inputTokens : 0;
|
|
381
|
+
var outTok = _isNonNegFinite(costBasis.outputTokens) ? costBasis.outputTokens : 0;
|
|
382
|
+
return (inTok / COST_RATE_TOKEN_UNIT) * descriptor.costPer1kInputTokens +
|
|
383
|
+
(outTok / COST_RATE_TOKEN_UNIT) * descriptor.costPer1kOutputTokens;
|
|
384
|
+
}
|
|
385
|
+
return descriptor.costPer1kInputTokens + descriptor.costPer1kOutputTokens;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function route(routeOpts) {
|
|
389
|
+
routeOpts = routeOpts || {};
|
|
390
|
+
validateOpts(routeOpts, ["requirements", "fallback", "costBasis"], "ai.capability.route");
|
|
391
|
+
var requirements = _validateRequirements(routeOpts.requirements);
|
|
392
|
+
var costBasis = null;
|
|
393
|
+
if (routeOpts.costBasis != null) {
|
|
394
|
+
if (typeof routeOpts.costBasis !== "object" || Array.isArray(routeOpts.costBasis)) {
|
|
395
|
+
throw new AiCapabilityError("aiCapability/bad-requirements",
|
|
396
|
+
"ai.capability.route: costBasis must be a plain object { inputTokens, outputTokens }");
|
|
397
|
+
}
|
|
398
|
+
validateOpts(routeOpts.costBasis, ["inputTokens", "outputTokens"],
|
|
399
|
+
"ai.capability.route costBasis");
|
|
400
|
+
// A malformed costBasis field silently underprices a candidate
|
|
401
|
+
// and biases the "cheapest" choice toward the wrong model — fail
|
|
402
|
+
// fast instead. An absent field is fine (treated as 0 tokens on
|
|
403
|
+
// that side); a present-but-non-numeric field is rejected.
|
|
404
|
+
var cbFields = ["inputTokens", "outputTokens"];
|
|
405
|
+
for (var ci = 0; ci < cbFields.length; ci++) {
|
|
406
|
+
var ck = cbFields[ci];
|
|
407
|
+
if (routeOpts.costBasis[ck] != null && !_isNonNegFinite(routeOpts.costBasis[ck])) {
|
|
408
|
+
throw new AiCapabilityError("aiCapability/bad-requirements",
|
|
409
|
+
"ai.capability.route: costBasis." + ck + " must be a non-negative finite number");
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
costBasis = routeOpts.costBasis;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// Collect every satisfying model, then pick the cheapest. Tie
|
|
416
|
+
// break by model id (lexicographic) so the choice is deterministic
|
|
417
|
+
// across calls and across nodes.
|
|
418
|
+
var candidates = [];
|
|
419
|
+
var modelIds = Array.from(registry.keys());
|
|
420
|
+
for (var i = 0; i < modelIds.length; i++) {
|
|
421
|
+
var d = registry.get(modelIds[i]);
|
|
422
|
+
if (_evaluate(d, requirements).ok) {
|
|
423
|
+
candidates.push({ modelId: modelIds[i], descriptor: d, cost: _estimateCost(d, costBasis) });
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
candidates.sort(function (x, y) {
|
|
427
|
+
if (x.cost !== y.cost) return x.cost - y.cost;
|
|
428
|
+
return x.modelId < y.modelId ? -1 : (x.modelId > y.modelId ? 1 : 0);
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
if (candidates.length > 0) {
|
|
432
|
+
var pick = candidates[0];
|
|
433
|
+
_emitAudit("ai/capability-routed", "allowed", {
|
|
434
|
+
modelId: pick.modelId, candidateCount: candidates.length,
|
|
435
|
+
estimatedCost: pick.cost, requirements: requirements,
|
|
436
|
+
});
|
|
437
|
+
return {
|
|
438
|
+
modelId: pick.modelId,
|
|
439
|
+
descriptor: pick.descriptor,
|
|
440
|
+
estimatedCost: pick.cost,
|
|
441
|
+
reason: "cheapest-of-" + candidates.length,
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// No model satisfies the requirements.
|
|
446
|
+
if (routeOpts.fallback != null) {
|
|
447
|
+
var fb = registry.get(routeOpts.fallback);
|
|
448
|
+
if (!fb) {
|
|
449
|
+
throw new AiCapabilityError("aiCapability/unknown-model",
|
|
450
|
+
"ai.capability.route: fallback '" + routeOpts.fallback + "' is not a registered model");
|
|
451
|
+
}
|
|
452
|
+
_emitAudit("ai/capability-fallback", "allowed", {
|
|
453
|
+
modelId: routeOpts.fallback, requirements: requirements,
|
|
454
|
+
});
|
|
455
|
+
return {
|
|
456
|
+
modelId: routeOpts.fallback,
|
|
457
|
+
descriptor: fb,
|
|
458
|
+
estimatedCost: _estimateCost(fb, costBasis),
|
|
459
|
+
reason: "fallback",
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
_emitAudit("ai/capability-no-candidate", "denied", { requirements: requirements });
|
|
464
|
+
throw new AiCapabilityError("aiCapability/no-candidate",
|
|
465
|
+
"ai.capability.route: no registered model satisfies the requirements " +
|
|
466
|
+
"and no fallback was supplied");
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
return {
|
|
470
|
+
describe: describe,
|
|
471
|
+
list: list,
|
|
472
|
+
register: register,
|
|
473
|
+
satisfies: satisfies,
|
|
474
|
+
route: route,
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
module.exports = {
|
|
479
|
+
create: create,
|
|
480
|
+
REASONING_TIERS: REASONING_TIERS,
|
|
481
|
+
AiCapabilityError: AiCapabilityError,
|
|
482
|
+
};
|
package/lib/ai-dp.js
ADDED
|
@@ -0,0 +1,539 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module b.ai.dp
|
|
4
|
+
* @nav Compliance
|
|
5
|
+
* @title Differential privacy
|
|
6
|
+
*
|
|
7
|
+
* @intro
|
|
8
|
+
* Float-safe differential-privacy mechanisms with per-scope privacy
|
|
9
|
+
* budgeting. Differential privacy adds calibrated noise to an
|
|
10
|
+
* aggregate so the output is provably insensitive to any single
|
|
11
|
+
* record — but the guarantee is fragile: Mironov (2012) showed that
|
|
12
|
+
* a Laplace mechanism implemented with naive double-precision
|
|
13
|
+
* sampling lets an attacker distinguish neighbouring datasets with
|
|
14
|
+
* > 35% probability from a <em>single</em> output, silently
|
|
15
|
+
* destroying the promise. This module ships only mechanisms whose
|
|
16
|
+
* sampling is hardened against that class of attack:
|
|
17
|
+
*
|
|
18
|
+
* - <strong>Laplace via the snapping mechanism</strong> (Mironov
|
|
19
|
+
* 2012): clamp to a bound, draw a CSPRNG sign + full-mantissa
|
|
20
|
+
* uniform, then round to a power-of-two grid — the rounding
|
|
21
|
+
* removes the exploitable low-order mantissa bits. Pure
|
|
22
|
+
* ε-differential privacy.
|
|
23
|
+
* - <strong>Discrete Gaussian</strong> (Canonne–Kamath–Steinke
|
|
24
|
+
* 2020): integer-exact rejection sampling built from
|
|
25
|
+
* Bernoulli(exp(−γ)) over exact rationals — no floating-point
|
|
26
|
+
* noise at all. (ε, δ)-differential privacy, integer-valued.
|
|
27
|
+
*
|
|
28
|
+
* All randomness comes from <code>b.crypto.generateBytes</code>
|
|
29
|
+
* (SHAKE256 over the OS CSPRNG), never <code>Math.random</code>.
|
|
30
|
+
*
|
|
31
|
+
* <code>b.ai.dp.budget({ scope, epsilon, delta })</code> tracks a
|
|
32
|
+
* privacy budget per scope (per-user / per-tenant / per-query-class)
|
|
33
|
+
* and refuses a <code>consume</code> that would exceed it.
|
|
34
|
+
* Composition is accounted two ways:
|
|
35
|
+
*
|
|
36
|
+
* - <code>"basic"</code> (default) — sum the per-release ε and δ.
|
|
37
|
+
* Always valid; conservative.
|
|
38
|
+
* - <code>"rdp"</code> — a Rényi DP accountant (Mironov 2017) tracks
|
|
39
|
+
* RDP across a grid of orders and converts to (ε, δ) at the
|
|
40
|
+
* scope's δ, giving a much tighter bound under repeated Gaussian
|
|
41
|
+
* releases. Requires <code>delta > 0</code>.
|
|
42
|
+
*
|
|
43
|
+
* NIST SP 800-226 (2025) is the evaluation standard for these
|
|
44
|
+
* guarantees; Dwork & Roth, "The Algorithmic Foundations of
|
|
45
|
+
* Differential Privacy", is the canonical reference.
|
|
46
|
+
*
|
|
47
|
+
* The exponential and sparse-vector mechanisms are
|
|
48
|
+
* deferred-with-condition: their float-safe constructions (the
|
|
49
|
+
* base-2 / permute-and-flip exponential mechanism, Ilvento 2019; a
|
|
50
|
+
* snapped sparse-vector) are a distinct effort, and shipping them
|
|
51
|
+
* float-<em>unsafe</em> would defeat the module's purpose. They
|
|
52
|
+
* re-open on operator demand with the named construction.
|
|
53
|
+
*
|
|
54
|
+
* @card
|
|
55
|
+
* Float-safe differential privacy — snapping-mechanism Laplace
|
|
56
|
+
* (Mironov 2012) + discrete Gaussian (CKS20), CSPRNG noise, per-
|
|
57
|
+
* scope ε/δ budgets with basic + Rényi-DP accounting.
|
|
58
|
+
*/
|
|
59
|
+
|
|
60
|
+
var bCrypto = require("./crypto");
|
|
61
|
+
var validateOpts = require("./validate-opts");
|
|
62
|
+
var lazyRequire = require("./lazy-require");
|
|
63
|
+
var { defineClass } = require("./framework-error");
|
|
64
|
+
|
|
65
|
+
var AiDpError = defineClass("AiDpError", { alwaysPermanent: true });
|
|
66
|
+
|
|
67
|
+
var audit = lazyRequire(function () { return require("./audit"); });
|
|
68
|
+
|
|
69
|
+
var MECHANISMS = ["laplace", "gaussian"];
|
|
70
|
+
var ACCOUNTINGS = ["basic", "rdp"];
|
|
71
|
+
|
|
72
|
+
// Rational approximation precision for a real-valued σ² fed to the
|
|
73
|
+
// integer-exact discrete-Gaussian sampler. 2^32 keeps the deviation
|
|
74
|
+
// from the target σ² below 2^-32 — far under the noise scale — while
|
|
75
|
+
// keeping the BigInt denominators bounded.
|
|
76
|
+
var SIGMA2_RATIONAL_DEN = 4294967296; // allow:raw-byte-literal — 2^32 rational-approx denominator, not a byte size
|
|
77
|
+
|
|
78
|
+
// ---- Minimal exact rational (BigInt num / den, den > 0) ----
|
|
79
|
+
|
|
80
|
+
function _gcd(a, b) {
|
|
81
|
+
a = a < 0n ? -a : a;
|
|
82
|
+
b = b < 0n ? -b : b;
|
|
83
|
+
while (b) { var t = a % b; a = b; b = t; }
|
|
84
|
+
return a;
|
|
85
|
+
}
|
|
86
|
+
function _fr(num, den) {
|
|
87
|
+
if (den < 0n) { num = -num; den = -den; }
|
|
88
|
+
var g = _gcd(num, den) || 1n;
|
|
89
|
+
return { num: num / g, den: den / g };
|
|
90
|
+
}
|
|
91
|
+
function _frFromFloat(x, den) {
|
|
92
|
+
// den is a Number power-of-two-ish denominator; round(x*den)/den.
|
|
93
|
+
return _fr(BigInt(Math.round(x * den)), BigInt(den));
|
|
94
|
+
}
|
|
95
|
+
function _frMul(a, b) { return _fr(a.num * b.num, a.den * b.den); }
|
|
96
|
+
function _frSub(a, b) { return _fr(a.num * b.den - b.num * a.den, a.den * b.den); }
|
|
97
|
+
function _frLte(a, b) { return a.num * b.den <= b.num * a.den; } // a <= b
|
|
98
|
+
function _frGt(a, b) { return a.num * b.den > b.num * a.den; } // a > b
|
|
99
|
+
|
|
100
|
+
// ---- CSPRNG primitives (all noise routes through b.crypto) ----
|
|
101
|
+
|
|
102
|
+
// Uniform BigInt in [0, m) via rejection sampling on CSPRNG bytes —
|
|
103
|
+
// no modulo bias.
|
|
104
|
+
function _uniformBelow(m) {
|
|
105
|
+
if (m <= 0n) throw new AiDpError("aiDp/internal", "ai.dp: _uniformBelow needs m > 0");
|
|
106
|
+
if (m === 1n) return 0n;
|
|
107
|
+
var bits = m.toString(2).length;
|
|
108
|
+
var bytes = Math.ceil(bits / 8); // allow:raw-byte-literal — bits-per-byte divisor, not a size
|
|
109
|
+
var mask = (1n << BigInt(bits)) - 1n;
|
|
110
|
+
for (;;) {
|
|
111
|
+
var buf = bCrypto.generateBytes(bytes);
|
|
112
|
+
var x = 0n;
|
|
113
|
+
for (var i = 0; i < bytes; i++) x = (x << 8n) | BigInt(buf[i]);
|
|
114
|
+
x = x & mask;
|
|
115
|
+
if (x < m) return x;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Uniform double in (0, 1] with full 53-bit mantissa entropy — the
|
|
120
|
+
// snapping mechanism's noise source. A 53-bit integer is drawn via
|
|
121
|
+
// the BigInt rejection sampler (accumulating 53 bits in a JS Number
|
|
122
|
+
// would overflow the 2^53 safe-integer range and skew the draw), then
|
|
123
|
+
// mapped (val + 1) / 2^53 → (0, 1].
|
|
124
|
+
var TWO_POW_53 = 9007199254740992; // allow:raw-byte-literal — 2^53 mantissa range, not a byte size
|
|
125
|
+
function _uniformOpen() {
|
|
126
|
+
var v = Number(_uniformBelow(9007199254740992n)); // [0, 2^53) exact
|
|
127
|
+
return (v + 1) / TWO_POW_53; // (0, 1]
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function _randomSign() {
|
|
131
|
+
return (bCrypto.generateBytes(1)[0] & 1) === 1 ? 1 : -1;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ---- Canonne–Kamath–Steinke 2020 integer-exact samplers ----
|
|
135
|
+
// Ported verbatim from the reference implementation
|
|
136
|
+
// (github.com/IBM/discrete-gaussian-differential-privacy). All
|
|
137
|
+
// arithmetic is exact (BigInt rationals); no floating-point noise.
|
|
138
|
+
|
|
139
|
+
function _bernoulli(p) { // p rational in [0,1]
|
|
140
|
+
return _uniformBelow(p.den) < p.num ? 1 : 0;
|
|
141
|
+
}
|
|
142
|
+
function _bernoulliExp1(x) { // x rational in [0,1]
|
|
143
|
+
var k = 1n;
|
|
144
|
+
for (;;) {
|
|
145
|
+
if (_bernoulli(_fr(x.num, x.den * k)) === 1) k = k + 1n;
|
|
146
|
+
else break;
|
|
147
|
+
}
|
|
148
|
+
return Number(k % 2n);
|
|
149
|
+
}
|
|
150
|
+
function _bernoulliExp(x) { // x rational >= 0
|
|
151
|
+
while (_frGt(x, _fr(1n, 1n))) {
|
|
152
|
+
if (_bernoulliExp1(_fr(1n, 1n)) === 1) x = _frSub(x, _fr(1n, 1n));
|
|
153
|
+
else return 0;
|
|
154
|
+
}
|
|
155
|
+
return _bernoulliExp1(x);
|
|
156
|
+
}
|
|
157
|
+
function _geometricExpSlow(x) { // x rational >= 0
|
|
158
|
+
var k = 0n;
|
|
159
|
+
for (;;) {
|
|
160
|
+
if (_bernoulliExp(x) === 1) k = k + 1n;
|
|
161
|
+
else return k;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
function _geometricExpFast(x) { // x rational > 0; returns BigInt
|
|
165
|
+
if (x.num === 0n) return 0n;
|
|
166
|
+
var t = x.den;
|
|
167
|
+
var u;
|
|
168
|
+
for (;;) {
|
|
169
|
+
u = _uniformBelow(t);
|
|
170
|
+
if (_bernoulliExp(_fr(u, t)) === 1) break;
|
|
171
|
+
}
|
|
172
|
+
var v = _geometricExpSlow(_fr(1n, 1n));
|
|
173
|
+
var value = v * t + u;
|
|
174
|
+
return value / x.num; // integer division
|
|
175
|
+
}
|
|
176
|
+
function _sampleDLaplace(scaleNum, scaleDen) { // Lap_Z(scale); returns BigInt
|
|
177
|
+
var invScale = _fr(scaleDen, scaleNum); // 1 / scale
|
|
178
|
+
for (;;) {
|
|
179
|
+
var sign = _bernoulli(_fr(1n, 2n));
|
|
180
|
+
var magnitude = _geometricExpFast(invScale);
|
|
181
|
+
if (sign === 1 && magnitude === 0n) continue;
|
|
182
|
+
return magnitude * BigInt(1 - 2 * sign);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
function _floorSqrtFrac(fr) { // floor(sqrt(rational)); returns BigInt
|
|
186
|
+
var num = fr.num, den = fr.den;
|
|
187
|
+
var a = 0n, b = 1n;
|
|
188
|
+
while (b * b * den <= num) b = 2n * b;
|
|
189
|
+
while (a + 1n < b) {
|
|
190
|
+
var c = (a + b) / 2n;
|
|
191
|
+
if (c * c * den <= num) a = c; else b = c;
|
|
192
|
+
}
|
|
193
|
+
return a;
|
|
194
|
+
}
|
|
195
|
+
function _sampleDGauss(sigma2) { // sigma2 rational > 0; returns BigInt
|
|
196
|
+
var t = _floorSqrtFrac(sigma2) + 1n;
|
|
197
|
+
var two_sigma2 = _fr(2n * sigma2.num, sigma2.den); // 2 * sigma2
|
|
198
|
+
var sigma2_over_t = _fr(sigma2.num, sigma2.den * t); // sigma2 / t
|
|
199
|
+
for (;;) {
|
|
200
|
+
var candidate = _sampleDLaplace(t, 1n);
|
|
201
|
+
var absC = candidate < 0n ? -candidate : candidate;
|
|
202
|
+
var diff = _frSub(_fr(absC, 1n), sigma2_over_t); // |candidate| - sigma2/t
|
|
203
|
+
// bias = diff^2 / (2 sigma2) — multiply diff^2 by the reciprocal of 2σ².
|
|
204
|
+
var diff2 = _fr(diff.num * diff.num, diff.den * diff.den);
|
|
205
|
+
var bias = _frMul(diff2, _fr(two_sigma2.den, two_sigma2.num));
|
|
206
|
+
if (_bernoulliExp(bias) === 1) return candidate;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ---- Snapping-mechanism Laplace (Mironov 2012), float-safe ----
|
|
211
|
+
|
|
212
|
+
function _clamp(x, bound) {
|
|
213
|
+
if (x < -bound) return -bound;
|
|
214
|
+
if (x > bound) return bound;
|
|
215
|
+
return x;
|
|
216
|
+
}
|
|
217
|
+
function _snappingLaplace(value, scale, bound) {
|
|
218
|
+
// scale = sensitivity / epsilon (Laplace b). bound B clamps the
|
|
219
|
+
// input + output; the privacy guarantee depends on it. Lambda is
|
|
220
|
+
// the smallest power of two >= scale, so inner / Lambda and
|
|
221
|
+
// Lambda * round(...) are exact float ops — that is what removes
|
|
222
|
+
// the attackable low-order bits the naive sampler leaks.
|
|
223
|
+
var xc = _clamp(value, bound);
|
|
224
|
+
var S = _randomSign();
|
|
225
|
+
var U = _uniformOpen(); // (0, 1]
|
|
226
|
+
var lambdaPow = Math.pow(2, Math.ceil(Math.log2(scale)));
|
|
227
|
+
var inner = xc + S * scale * Math.log(U);
|
|
228
|
+
var rounded = lambdaPow * Math.round(inner / lambdaPow);
|
|
229
|
+
return _clamp(rounded, bound);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ---- Rényi-DP costs (Mironov 2017) ----
|
|
233
|
+
|
|
234
|
+
var RDP_ORDERS = [1.25, 1.5, 1.75, 2, 2.5, 3, 4, 5, 6, 8, 12, 16, 24, 32, 48, 64, 128, 256]; // allow:raw-byte-literal — Rényi DP orders (α), not byte sizes
|
|
235
|
+
|
|
236
|
+
// Gaussian mechanism with noise-to-sensitivity z = sigma / sensitivity:
|
|
237
|
+
// RDP(alpha) = alpha / (2 z^2).
|
|
238
|
+
function _rdpGaussian(alpha, sigma, sensitivity) {
|
|
239
|
+
var z = sigma / sensitivity;
|
|
240
|
+
return alpha / (2 * z * z);
|
|
241
|
+
}
|
|
242
|
+
// Laplace mechanism with pure-DP parameter eps0 (= sensitivity / scale):
|
|
243
|
+
// RDP(alpha) = (1/(alpha-1)) * ln( (alpha/(2alpha-1)) e^{(alpha-1)eps0}
|
|
244
|
+
// + ((alpha-1)/(2alpha-1)) e^{-alpha eps0} ).
|
|
245
|
+
function _rdpLaplace(alpha, eps0) {
|
|
246
|
+
var a = alpha;
|
|
247
|
+
var num1 = a / (2 * a - 1);
|
|
248
|
+
var num2 = (a - 1) / (2 * a - 1);
|
|
249
|
+
var term = num1 * Math.exp((a - 1) * eps0) + num2 * Math.exp(-a * eps0);
|
|
250
|
+
return Math.log(term) / (a - 1);
|
|
251
|
+
}
|
|
252
|
+
// Convert an RDP curve (rdp[order]) to (eps, delta): the standard
|
|
253
|
+
// RDP -> DP bound eps(delta) = min_alpha ( rdp(alpha) + ln(1/delta)/(alpha-1) ).
|
|
254
|
+
function _rdpToEpsilon(rdpByOrder, delta) {
|
|
255
|
+
var best = Infinity;
|
|
256
|
+
for (var i = 0; i < RDP_ORDERS.length; i++) {
|
|
257
|
+
var a = RDP_ORDERS[i];
|
|
258
|
+
var e = rdpByOrder[i] + Math.log(1 / delta) / (a - 1);
|
|
259
|
+
if (e < best) best = e;
|
|
260
|
+
}
|
|
261
|
+
return best;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ---- mechanism descriptor ----
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* @primitive b.ai.dp.mechanism
|
|
268
|
+
* @signature b.ai.dp.mechanism(opts)
|
|
269
|
+
* @since 0.12.29
|
|
270
|
+
* @status stable
|
|
271
|
+
* @compliance gdpr, soc2
|
|
272
|
+
* @related b.ai.dp.budget, b.ai.quota.create
|
|
273
|
+
*
|
|
274
|
+
* Build a float-safe DP noise mechanism. <code>type: "laplace"</code>
|
|
275
|
+
* is the snapping mechanism (pure ε-DP, real-valued, needs a
|
|
276
|
+
* <code>bound</code>); <code>type: "gaussian"</code> is the discrete
|
|
277
|
+
* Gaussian (integer-valued, (ε, δ)-DP, needs <code>delta</code>).
|
|
278
|
+
* Pass the result to <code>budget.consume(mechanism, value)</code>.
|
|
279
|
+
*
|
|
280
|
+
* @opts
|
|
281
|
+
* {
|
|
282
|
+
* type: string, // "laplace" | "gaussian"
|
|
283
|
+
* sensitivity: number, // required, > 0 (L1 for laplace, L1/integer for gaussian)
|
|
284
|
+
* epsilon: number, // required, > 0 (per-release ε; ε ≤ 1 for the
|
|
285
|
+
* // classic Gaussian calibration)
|
|
286
|
+
* delta?: number, // gaussian only, required, 0 < δ < 1
|
|
287
|
+
* bound?: number, // laplace only, required, > 0 — clamp bound B
|
|
288
|
+
* }
|
|
289
|
+
*
|
|
290
|
+
* @example
|
|
291
|
+
* var lap = b.ai.dp.mechanism({ type: "laplace", sensitivity: 1, epsilon: 0.5, bound: 1000 });
|
|
292
|
+
* var gss = b.ai.dp.mechanism({ type: "gaussian", sensitivity: 1, epsilon: 0.5, delta: 1e-6 });
|
|
293
|
+
*/
|
|
294
|
+
function mechanism(opts) {
|
|
295
|
+
validateOpts.requireObject(opts, "ai.dp.mechanism", AiDpError);
|
|
296
|
+
validateOpts(opts, ["type", "sensitivity", "epsilon", "delta", "bound"], "ai.dp.mechanism");
|
|
297
|
+
|
|
298
|
+
if (MECHANISMS.indexOf(opts.type) === -1) {
|
|
299
|
+
throw new AiDpError("aiDp/bad-mechanism",
|
|
300
|
+
"ai.dp.mechanism: type must be one of " + MECHANISMS.join(" / ") +
|
|
301
|
+
" (exponential / sparse-vector are deferred — their float-safe constructions " +
|
|
302
|
+
"re-open on demand)");
|
|
303
|
+
}
|
|
304
|
+
if (typeof opts.sensitivity !== "number" || !isFinite(opts.sensitivity) || opts.sensitivity <= 0) {
|
|
305
|
+
throw new AiDpError("aiDp/bad-sensitivity",
|
|
306
|
+
"ai.dp.mechanism: sensitivity must be a positive finite number");
|
|
307
|
+
}
|
|
308
|
+
if (typeof opts.epsilon !== "number" || !isFinite(opts.epsilon) || opts.epsilon <= 0) {
|
|
309
|
+
throw new AiDpError("aiDp/bad-epsilon",
|
|
310
|
+
"ai.dp.mechanism: epsilon must be a positive finite number");
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (opts.type === "laplace") {
|
|
314
|
+
if (typeof opts.bound !== "number" || !isFinite(opts.bound) || opts.bound <= 0) {
|
|
315
|
+
throw new AiDpError("aiDp/bad-bound",
|
|
316
|
+
"ai.dp.mechanism: laplace requires bound > 0 (the snapping clamp; the " +
|
|
317
|
+
"privacy guarantee depends on it)");
|
|
318
|
+
}
|
|
319
|
+
var scale = opts.sensitivity / opts.epsilon;
|
|
320
|
+
return Object.freeze({
|
|
321
|
+
type: "laplace", sensitivity: opts.sensitivity, epsilon: opts.epsilon,
|
|
322
|
+
delta: 0, scale: scale, bound: opts.bound,
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// gaussian
|
|
327
|
+
if (typeof opts.delta !== "number" || !isFinite(opts.delta) || opts.delta <= 0 || opts.delta >= 1) {
|
|
328
|
+
throw new AiDpError("aiDp/bad-delta",
|
|
329
|
+
"ai.dp.mechanism: gaussian requires 0 < delta < 1");
|
|
330
|
+
}
|
|
331
|
+
if (opts.epsilon > 1) {
|
|
332
|
+
throw new AiDpError("aiDp/epsilon-too-large",
|
|
333
|
+
"ai.dp.mechanism: the classic Gaussian calibration is proven for epsilon <= 1; " +
|
|
334
|
+
"split into multiple releases under an rdp budget, or the analytic Gaussian " +
|
|
335
|
+
"mechanism (Balle-Wang 2018) re-opens this path on demand");
|
|
336
|
+
}
|
|
337
|
+
// Classic Gaussian calibration (Dwork & Roth Thm 3.22), valid for ε ≤ 1.
|
|
338
|
+
var sigma = Math.sqrt(2 * Math.log(1.25 / opts.delta)) * opts.sensitivity / opts.epsilon;
|
|
339
|
+
return Object.freeze({
|
|
340
|
+
type: "gaussian", sensitivity: opts.sensitivity, epsilon: opts.epsilon,
|
|
341
|
+
delta: opts.delta, sigma: sigma, sigma2: sigma * sigma,
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Apply a mechanism's noise to a numeric value (no accounting — the
|
|
346
|
+
// budget wraps this).
|
|
347
|
+
function _applyMechanism(m, value) {
|
|
348
|
+
if (typeof value !== "number" || !isFinite(value)) {
|
|
349
|
+
throw new AiDpError("aiDp/bad-value", "ai.dp: value must be a finite number");
|
|
350
|
+
}
|
|
351
|
+
if (m.type === "laplace") {
|
|
352
|
+
return _snappingLaplace(value, m.scale, m.bound);
|
|
353
|
+
}
|
|
354
|
+
// gaussian — discrete, integer noise added to the (rounded) value.
|
|
355
|
+
var sigma2Frac = _frFromFloat(m.sigma2, SIGMA2_RATIONAL_DEN);
|
|
356
|
+
var noise = _sampleDGauss(sigma2Frac);
|
|
357
|
+
return Math.round(value) + Number(noise);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function _mechRdp(m, orderIndex) {
|
|
361
|
+
var alpha = RDP_ORDERS[orderIndex];
|
|
362
|
+
if (m.type === "gaussian") return _rdpGaussian(alpha, m.sigma, m.sensitivity);
|
|
363
|
+
return _rdpLaplace(alpha, m.epsilon);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// ---- per-scope budget ----
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* @primitive b.ai.dp.budget
|
|
370
|
+
* @signature b.ai.dp.budget(opts)
|
|
371
|
+
* @since 0.12.29
|
|
372
|
+
* @status stable
|
|
373
|
+
* @compliance gdpr, soc2
|
|
374
|
+
* @related b.ai.dp.mechanism, b.ai.quota.create
|
|
375
|
+
*
|
|
376
|
+
* Track a differential-privacy budget for one scope (per-user /
|
|
377
|
+
* per-tenant / per-query-class) and refuse a release that would
|
|
378
|
+
* exceed it. Returns <code>{ consume, remaining, spent, reset }</code>.
|
|
379
|
+
* <code>consume(mechanism, value)</code> adds the mechanism's noise,
|
|
380
|
+
* charges the accountant, and throws <code>aiDp/budget-exhausted</code>
|
|
381
|
+
* if the release would push the scope past its (ε, δ). With
|
|
382
|
+
* <code>accounting: "rdp"</code> the charge is accounted via Rényi DP
|
|
383
|
+
* for a tight composition bound (requires <code>delta > 0</code>);
|
|
384
|
+
* <code>"basic"</code> (default) sums per-release ε and δ.
|
|
385
|
+
*
|
|
386
|
+
* @opts
|
|
387
|
+
* {
|
|
388
|
+
* scope: string, // required, the budget scope id
|
|
389
|
+
* epsilon: number, // required, total ε budget (> 0)
|
|
390
|
+
* delta?: number, // total δ budget (>= 0; required > 0 for rdp / gaussian)
|
|
391
|
+
* accounting?: string, // "basic" (default) | "rdp"
|
|
392
|
+
* audit?: boolean, // default: true
|
|
393
|
+
* }
|
|
394
|
+
*
|
|
395
|
+
* @example
|
|
396
|
+
* var b1 = b.ai.dp.budget({ scope: "tenant-acme:daily", epsilon: 3, delta: 1e-6, accounting: "rdp" });
|
|
397
|
+
* var m = b.ai.dp.mechanism({ type: "gaussian", sensitivity: 1, epsilon: 0.5, delta: 1e-6 });
|
|
398
|
+
* var out = b1.consume(m, trueCount);
|
|
399
|
+
* // → { value: <noised>, cost: { epsilon: 0.5, delta: 1e-6 }, remaining: { epsilon, delta } }
|
|
400
|
+
*/
|
|
401
|
+
function budget(opts) {
|
|
402
|
+
validateOpts.requireObject(opts, "ai.dp.budget", AiDpError);
|
|
403
|
+
validateOpts(opts, ["scope", "epsilon", "delta", "accounting", "audit"], "ai.dp.budget");
|
|
404
|
+
|
|
405
|
+
validateOpts.requireNonEmptyString(opts.scope,
|
|
406
|
+
"ai.dp.budget: scope", AiDpError, "aiDp/bad-scope");
|
|
407
|
+
if (typeof opts.epsilon !== "number" || !isFinite(opts.epsilon) || opts.epsilon <= 0) {
|
|
408
|
+
throw new AiDpError("aiDp/bad-epsilon", "ai.dp.budget: epsilon must be a positive finite number");
|
|
409
|
+
}
|
|
410
|
+
var totalEpsilon = opts.epsilon;
|
|
411
|
+
var totalDelta = (opts.delta == null) ? 0 : opts.delta;
|
|
412
|
+
if (typeof totalDelta !== "number" || !isFinite(totalDelta) || totalDelta < 0 || totalDelta >= 1) {
|
|
413
|
+
throw new AiDpError("aiDp/bad-delta", "ai.dp.budget: delta must be in [0, 1)");
|
|
414
|
+
}
|
|
415
|
+
var accounting = (opts.accounting == null) ? "basic" : opts.accounting;
|
|
416
|
+
if (ACCOUNTINGS.indexOf(accounting) === -1) {
|
|
417
|
+
throw new AiDpError("aiDp/bad-accounting",
|
|
418
|
+
"ai.dp.budget: accounting must be one of " + ACCOUNTINGS.join(" / "));
|
|
419
|
+
}
|
|
420
|
+
if (accounting === "rdp" && totalDelta <= 0) {
|
|
421
|
+
throw new AiDpError("aiDp/bad-accounting",
|
|
422
|
+
"ai.dp.budget: rdp accounting requires delta > 0 (the RDP→(ε,δ) conversion is " +
|
|
423
|
+
"undefined at delta = 0; use basic accounting for pure-ε budgets)");
|
|
424
|
+
}
|
|
425
|
+
var auditOn = opts.audit !== false;
|
|
426
|
+
|
|
427
|
+
var scope = opts.scope;
|
|
428
|
+
var spentEpsilon = 0; // basic accounting
|
|
429
|
+
var spentDelta = 0;
|
|
430
|
+
var rdp = RDP_ORDERS.map(function () { return 0; }); // rdp accounting
|
|
431
|
+
|
|
432
|
+
function _emitAudit(action, outcome, metadata) {
|
|
433
|
+
if (!auditOn) return;
|
|
434
|
+
try { audit().safeEmit({ action: action, outcome: outcome, metadata: metadata || {} }); }
|
|
435
|
+
catch (_e) { /* drop-silent */ }
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function _currentEpsilon(rdpCurve) {
|
|
439
|
+
if (accounting === "basic") return spentEpsilon;
|
|
440
|
+
return _rdpToEpsilon(rdpCurve, totalDelta);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function remaining() {
|
|
444
|
+
if (accounting === "basic") {
|
|
445
|
+
return {
|
|
446
|
+
epsilon: Math.max(0, totalEpsilon - spentEpsilon),
|
|
447
|
+
delta: Math.max(0, totalDelta - spentDelta),
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
return { epsilon: Math.max(0, totalEpsilon - _rdpToEpsilon(rdp, totalDelta)), delta: totalDelta };
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function spent() {
|
|
454
|
+
if (accounting === "basic") return { epsilon: spentEpsilon, delta: spentDelta };
|
|
455
|
+
return { epsilon: _rdpToEpsilon(rdp, totalDelta), delta: totalDelta };
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function consume(m, value) {
|
|
459
|
+
if (!m || typeof m !== "object" || MECHANISMS.indexOf(m.type) === -1) {
|
|
460
|
+
throw new AiDpError("aiDp/bad-mechanism",
|
|
461
|
+
"ai.dp.budget.consume: first argument must be a b.ai.dp.mechanism");
|
|
462
|
+
}
|
|
463
|
+
if (m.type === "gaussian" && totalDelta <= 0) {
|
|
464
|
+
throw new AiDpError("aiDp/bad-delta",
|
|
465
|
+
"ai.dp.budget.consume: a gaussian mechanism needs a scope delta > 0");
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// Prospective accounting: would this release fit under the budget?
|
|
469
|
+
var cost;
|
|
470
|
+
if (accounting === "basic") {
|
|
471
|
+
if (spentEpsilon + m.epsilon > totalEpsilon + 1e-12 ||
|
|
472
|
+
spentDelta + m.delta > totalDelta + 1e-12) {
|
|
473
|
+
_emitAudit("dp/budget-exhausted", "denied", {
|
|
474
|
+
scope: scope, accounting: accounting, mechanism: m.type,
|
|
475
|
+
requestEpsilon: m.epsilon, requestDelta: m.delta,
|
|
476
|
+
spentEpsilon: spentEpsilon, totalEpsilon: totalEpsilon,
|
|
477
|
+
});
|
|
478
|
+
throw new AiDpError("aiDp/budget-exhausted",
|
|
479
|
+
"ai.dp.budget.consume: scope '" + scope + "' would spend ε=" +
|
|
480
|
+
(spentEpsilon + m.epsilon) + "/" + totalEpsilon + ", δ=" +
|
|
481
|
+
(spentDelta + m.delta) + "/" + totalDelta + "; refused");
|
|
482
|
+
}
|
|
483
|
+
cost = { epsilon: m.epsilon, delta: m.delta };
|
|
484
|
+
} else {
|
|
485
|
+
var trial = rdp.map(function (r, i) { return r + _mechRdp(m, i); });
|
|
486
|
+
var trialEps = _rdpToEpsilon(trial, totalDelta);
|
|
487
|
+
if (trialEps > totalEpsilon + 1e-12) {
|
|
488
|
+
_emitAudit("dp/budget-exhausted", "denied", {
|
|
489
|
+
scope: scope, accounting: accounting, mechanism: m.type,
|
|
490
|
+
projectedEpsilon: trialEps, totalEpsilon: totalEpsilon,
|
|
491
|
+
});
|
|
492
|
+
throw new AiDpError("aiDp/budget-exhausted",
|
|
493
|
+
"ai.dp.budget.consume: scope '" + scope + "' would reach ε=" +
|
|
494
|
+
trialEps.toFixed(4) + " of " + totalEpsilon + " at δ=" + totalDelta + "; refused");
|
|
495
|
+
}
|
|
496
|
+
var before = _rdpToEpsilon(rdp, totalDelta);
|
|
497
|
+
cost = { epsilon: trialEps - before, delta: 0 };
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// Charge, then sample. (Sampling never fails; charging first keeps
|
|
501
|
+
// the budget monotone even if a caller ignores the throw path.)
|
|
502
|
+
var noised = _applyMechanism(m, value);
|
|
503
|
+
if (accounting === "basic") {
|
|
504
|
+
spentEpsilon += m.epsilon;
|
|
505
|
+
spentDelta += m.delta;
|
|
506
|
+
} else {
|
|
507
|
+
rdp = rdp.map(function (r, i) { return r + _mechRdp(m, i); });
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
_emitAudit("dp/budget-consumed", "allowed", {
|
|
511
|
+
scope: scope, accounting: accounting, mechanism: m.type,
|
|
512
|
+
epsilon: m.epsilon, delta: m.delta,
|
|
513
|
+
});
|
|
514
|
+
return { value: noised, cost: cost, remaining: remaining() };
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function reset() {
|
|
518
|
+
spentEpsilon = 0;
|
|
519
|
+
spentDelta = 0;
|
|
520
|
+
rdp = RDP_ORDERS.map(function () { return 0; });
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
return {
|
|
524
|
+
consume: consume,
|
|
525
|
+
remaining: remaining,
|
|
526
|
+
spent: spent,
|
|
527
|
+
reset: reset,
|
|
528
|
+
scope: scope,
|
|
529
|
+
accounting: accounting,
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
module.exports = {
|
|
534
|
+
mechanism: mechanism,
|
|
535
|
+
budget: budget,
|
|
536
|
+
MECHANISMS: MECHANISMS,
|
|
537
|
+
ACCOUNTINGS: ACCOUNTINGS,
|
|
538
|
+
AiDpError: AiDpError,
|
|
539
|
+
};
|
package/lib/crypto.js
CHANGED
|
@@ -387,9 +387,16 @@ function random(byteLength) {
|
|
|
387
387
|
// when callers requested more. SHAKE256 is also already the
|
|
388
388
|
// framework's KDF / browser-side derivation primitive, so the same
|
|
389
389
|
// hash family does double duty.
|
|
390
|
-
|
|
391
|
-
|
|
390
|
+
//
|
|
391
|
+
// Node's SHAKE256 XOF is non-uniform at outputLength 1 (the byte
|
|
392
|
+
// values 0x00 and 0xff never occur and the low bit skews to ~0.54);
|
|
393
|
+
// outputLength >= 2 is uniform. Draw at least 2 bytes and slice so a
|
|
394
|
+
// 1-byte request still returns a uniform byte.
|
|
395
|
+
var drawN = n < 2 ? 2 : n;
|
|
396
|
+
var out = nodeCrypto.createHash("shake256", { outputLength: drawN })
|
|
397
|
+
.update(nodeCrypto.randomBytes(drawN))
|
|
392
398
|
.digest();
|
|
399
|
+
return drawN === n ? out : out.subarray(0, n);
|
|
393
400
|
}
|
|
394
401
|
|
|
395
402
|
/**
|
package/package.json
CHANGED
package/sbom.cdx.json
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
"$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
|
|
3
3
|
"bomFormat": "CycloneDX",
|
|
4
4
|
"specVersion": "1.5",
|
|
5
|
-
"serialNumber": "urn:uuid:
|
|
5
|
+
"serialNumber": "urn:uuid:ae86440d-174b-4c3d-8fee-c92df10a498a",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-05-
|
|
8
|
+
"timestamp": "2026-05-24T17:32:55.663Z",
|
|
9
9
|
"lifecycles": [
|
|
10
10
|
{
|
|
11
11
|
"phase": "build"
|
|
@@ -19,14 +19,14 @@
|
|
|
19
19
|
}
|
|
20
20
|
],
|
|
21
21
|
"component": {
|
|
22
|
-
"bom-ref": "@blamejs/core@0.12.
|
|
22
|
+
"bom-ref": "@blamejs/core@0.12.29",
|
|
23
23
|
"type": "application",
|
|
24
24
|
"name": "blamejs",
|
|
25
|
-
"version": "0.12.
|
|
25
|
+
"version": "0.12.29",
|
|
26
26
|
"scope": "required",
|
|
27
27
|
"author": "blamejs contributors",
|
|
28
28
|
"description": "The Node framework that owns its stack.",
|
|
29
|
-
"purl": "pkg:npm/%40blamejs/core@0.12.
|
|
29
|
+
"purl": "pkg:npm/%40blamejs/core@0.12.29",
|
|
30
30
|
"properties": [],
|
|
31
31
|
"externalReferences": [
|
|
32
32
|
{
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"components": [],
|
|
55
55
|
"dependencies": [
|
|
56
56
|
{
|
|
57
|
-
"ref": "@blamejs/core@0.12.
|
|
57
|
+
"ref": "@blamejs/core@0.12.29",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|