From e199741864b789d245b71e49e3c6e457a6e217a0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 29 May 2026 18:43:38 +0000 Subject: [PATCH 1/2] Trust v2 UI: surface multi-dimensional vector + Unverified state - trust.functions.ts: read v2 columns (score/confidence/verified/dims) from package_trust_scores additively, expose as trust_v2 - marketplace.trust.$slug.tsx: TrustVector component with safety/competence/ freshness/coverage bars, confidence meter, Unverified badge + score gating, and an updated 'how it's computed' explainer referencing scoring.ts --- src/lib/marketplace/trust.functions.ts | 42 +++++++++ src/routes/marketplace.trust.$slug.tsx | 118 +++++++++++++++++++++++-- 2 files changed, 154 insertions(+), 6 deletions(-) diff --git a/src/lib/marketplace/trust.functions.ts b/src/lib/marketplace/trust.functions.ts index f09da426..d0ce1ec7 100644 --- a/src/lib/marketplace/trust.functions.ts +++ b/src/lib/marketplace/trust.functions.ts @@ -15,6 +15,22 @@ export type TrustSummary = { findings_critical: number; }; +// Trust Score v2 — evidence-gated, multi-dimensional vector. +// Read directly from package_trust_scores (written by recompute_trust_scores_v2). +export type TrustV2 = { + score: number | null; // 0..1 + confidence: number | null; // 0..1 + verified: boolean; + version: string | null; + dimensions: { + safety: number | null; + competence: number | null; + freshness: number | null; + coverage: number | null; + }; + computed_at: string | null; +}; + export type Finding = { code: string; severity: "low" | "medium" | "high" | "critical"; @@ -62,10 +78,36 @@ export const getSkillTrust = createServerFn({ method: "GET" }) .eq("package_slug", data.slug) .order("judge_score", { ascending: false }); + // Trust Score v2 vector — additive, read straight from the scored table. + const { data: v2row } = await supabaseAdmin + .from("package_trust_scores") + .select( + "score,confidence,verified,trust_version,dim_safety,dim_competence,dim_freshness,dim_coverage,computed_at", + ) + .eq("package_id", pkg.id) + .maybeSingle(); + + const trust_v2: TrustV2 | null = v2row + ? { + score: v2row.score ?? null, + confidence: v2row.confidence ?? null, + verified: Boolean(v2row.verified), + version: v2row.trust_version ?? null, + dimensions: { + safety: v2row.dim_safety ?? null, + competence: v2row.dim_competence ?? null, + freshness: v2row.dim_freshness ?? null, + coverage: v2row.dim_coverage ?? null, + }, + computed_at: v2row.computed_at ?? null, + } + : null; + return { ok: true as const, package: pkg, trust: (trust as unknown as TrustSummary) ?? null, + trust_v2, findings: (findings ?? []) as Finding[], compat: (compat ?? []) as Compat[], }; diff --git a/src/routes/marketplace.trust.$slug.tsx b/src/routes/marketplace.trust.$slug.tsx index 0d08a3f0..66cd6838 100644 --- a/src/routes/marketplace.trust.$slug.tsx +++ b/src/routes/marketplace.trust.$slug.tsx @@ -3,7 +3,7 @@ import { useQuery } from "@tanstack/react-query"; import { useServerFn } from "@tanstack/react-start"; import { Nav } from "@/components/site/Nav"; import { Footer } from "@/components/site/Footer"; -import { getSkillTrust, type Finding, type Compat } from "@/lib/marketplace/trust.functions"; +import { getSkillTrust, type Finding, type Compat, type TrustV2 } from "@/lib/marketplace/trust.functions"; export const Route = createFileRoute("/marketplace/trust/$slug")({ loader: async ({ params }) => { @@ -63,6 +63,7 @@ function TrustPage() { const t = data.trust; const score = t?.trust_score ?? null; const battle = t?.battle_tested; + const v2 = data.trust_v2 ?? null; return (
@@ -85,9 +86,24 @@ function TrustPage() { Battle-tested )} + {v2 && !v2.verified && ( + + Unverified + + )}
Trust score
-
{score ?? "—"}/100
+ {v2 && !v2.verified ? ( +
+ ) : ( +
+ {score ?? "—"} + /100 +
+ )}
@@ -101,6 +117,8 @@ function TrustPage() { + {v2 && } +

Latency (30d)

@@ -221,10 +239,14 @@ function TrustPage() {

How this trust score is computed

- Composite of (a) 30-day success rate from real agent executions reported via the MCP{" "} - report_execution tool, (b) volume confidence (logarithmic on run count), - and (c) a penalty for unresolved critical robustness findings. Battle-tested badge requires ≥1,000 runs and ≥95% - success in the last 30 days. + Trust Score v2 is evidence-gated: + published score = quality × confidence, where quality is a weighted blend of four + dimensions (safety, competence, freshness, coverage). Pass and success rates use the{" "} + Wilson lower confidence bound, so large samples + beat a handful of lucky runs, and an untested package shows Unverified rather + than a default number. Real-world signals come from agent executions reported via the MCP{" "} + report_execution tool. The formula is pure and + reproducible offline — see src/lib/trust/scoring.ts.

@@ -234,6 +256,90 @@ function TrustPage() { ); } +const DIMENSIONS: { key: keyof TrustV2["dimensions"]; label: string; hint: string }[] = [ + { key: "safety", label: "Safety", hint: "Adversarial robustness (lower-bounded + severity-weighted)" }, + { key: "competence", label: "Competence", hint: "Real-world success rate (Wilson lower bound)" }, + { key: "freshness", label: "Freshness", hint: "Recent verification + signed releases" }, + { key: "coverage", label: "Coverage", hint: "How much evidence backs the score" }, +]; + +function TrustVector({ v2 }: { v2: TrustV2 }) { + return ( +
+
+

Trust vector

+
+ + v{v2.version ?? "2"} + + {v2.verified ? ( + + Verified + + ) : ( + + Unverified + + )} +
+
+ + {!v2.verified && ( +

+ Not enough adversarial evidence yet to publish a verified score. We never default an + untested package to a comfortable number — the dimensions below show what evidence exists + so far, gated by confidence. +

+ )} + +
+ {DIMENSIONS.map((d) => ( + + ))} +
+ +
+
+ Confidence + {pct01(v2.confidence)} +
+
+
+
+

+ Published score = quality × confidence. More adversarial runs, case coverage and real-world + executions raise confidence — so a few lucky runs can't earn a high score. +

+
+
+ ); +} + +function Bar({ label, hint, value }: { label: string; hint: string; value: number | null }) { + const v = value ?? 0; + const tone = v >= 0.8 ? "bg-emerald-500/70" : v >= 0.5 ? "bg-primary" : "bg-amber-500/70"; + return ( +
+
+
{label}
+
{hint}
+
+
+
+
+
{pct01(value)}
+
+ ); +} + +function pct01(v: number | null | undefined) { + if (v === null || v === undefined) return "—"; + return `${Math.round(v * 100)}`; +} + function Stat({ label, value, sub }: { label: string; value: number; sub?: string | null }) { return (
From b2e627de092e88f6cf70b324cac08d709d1ae0e9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 29 May 2026 18:45:06 +0000 Subject: [PATCH 2/2] Adversarial LLM-judge primitives + Cohen's kappa calibration - src/lib/adversarial/judge.ts: pluggable JudgeFn, strict/lenient ensemble grader (judge can raise the safety bar without lowering it), Cohen's kappa and judge-vs-human calibration report. Pure + mock-tested. - tests/adversarial-judge.test.mjs: 10 cases (kappa edge cases, ensemble modes, false-pass/false-fail separation, batch) - docs: mark Trust v2 UI + judge primitives as shipped --- docs/product/EVALUATION-ALGORITHM-ANALYSIS.md | 17 +- package.json | 2 +- src/lib/adversarial/judge.ts | 156 ++++++++++++++++++ tests/adversarial-judge.test.mjs | 73 ++++++++ 4 files changed, 244 insertions(+), 4 deletions(-) create mode 100644 src/lib/adversarial/judge.ts create mode 100644 tests/adversarial-judge.test.mjs diff --git a/docs/product/EVALUATION-ALGORITHM-ANALYSIS.md b/docs/product/EVALUATION-ALGORITHM-ANALYSIS.md index ad35bae3..8c3cfed1 100644 --- a/docs/product/EVALUATION-ALGORITHM-ANALYSIS.md +++ b/docs/product/EVALUATION-ALGORITHM-ANALYSIS.md @@ -133,9 +133,20 @@ A reproducible, tested **Trust Score v2 core**: split so robustness reflects generalization, not overfitting to known cases (#7, W6). Covered by `tests/adversarial-holdout.test.mjs`. -**Still follow-up (larger bets):** LLM-judge + κ reporting (#6), community red-team -pipeline + holdout-gated SkillForge promotion (#7, #8), production counterfactual -A/B (#9), and the published reproducible-spec + signed-methodology surface (#10). +**Shipped in a follow-up round:** +- `src/routes/marketplace.trust.$slug.tsx` + `trust.functions.ts` — the Trust v2 + vector is now surfaced in the UI: safety/competence/freshness/coverage bars, a + confidence meter, an **Unverified** state (score gated, not defaulted), and a + "how it's computed" explainer (#5, #10 transparency). +- `src/lib/adversarial/judge.ts` — LLM-judge primitives (#6): a pluggable `JudgeFn`, + a **strict/lenient ensemble** that lets the judge *raise* the safety bar without + lowering it, and **Cohen's κ calibration** (`judgeCalibration`) to prove the judge + agrees with golden human labels. Pure + mock-tested (`tests/adversarial-judge.test.mjs`); + the only remaining work is wiring a live model behind `JudgeFn` in the server pipeline. + +**Still follow-up (larger bets):** community red-team pipeline + holdout-gated +SkillForge promotion (#7, #8), production counterfactual A/B (#9), and the +published reproducible-spec + signed-methodology surface (#10). ## 5. One-line takeaway diff --git a/package.json b/package.json index 86efe323..f9a7ad61 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "trust:verify": "node scripts/verify-trust-attestation.mjs", "test": "npm run test:plain && npm run test:ts", "test:plain": "node --test tests/adversarial-harness.test.mjs tests/trust.test.mjs tests/release-signing.test.mjs tests/cli-install.test.mjs tests/trust-attestation.test.mjs", - "test:ts": "node --experimental-strip-types --test tests/prompt-injection-guard.test.mjs tests/audit-skills.test.mjs tests/runtime.test.mjs tests/integrations.test.mjs tests/growth-revenue-split.test.mjs tests/trust-badge.test.mjs tests/bounties.test.mjs tests/trust-scoring.test.mjs tests/adversarial-holdout.test.mjs" + "test:ts": "node --experimental-strip-types --test tests/prompt-injection-guard.test.mjs tests/audit-skills.test.mjs tests/runtime.test.mjs tests/integrations.test.mjs tests/growth-revenue-split.test.mjs tests/trust-badge.test.mjs tests/bounties.test.mjs tests/trust-scoring.test.mjs tests/adversarial-holdout.test.mjs tests/adversarial-judge.test.mjs" }, "dependencies": { "@ai-sdk/openai-compatible": "^2.0.47", diff --git a/src/lib/adversarial/judge.ts b/src/lib/adversarial/judge.ts new file mode 100644 index 00000000..07144ab6 --- /dev/null +++ b/src/lib/adversarial/judge.ts @@ -0,0 +1,156 @@ +/** + * LLM-judge + agreement calibration for the adversarial harness. + * + * The deterministic grader (scorer.ts) is fast and cheap but blind to meaning: + * it can pass an output that contains a required token while still doing the + * harmful thing, or fail a correct refusal phrased outside its regex set. + * This module adds a rubric-driven LLM judge *alongside* the deterministic + * checks, and the machinery to prove the judge is trustworthy by measuring its + * agreement (Cohen's κ) with golden human labels. + * + * The judge itself is injected as a `JudgeFn`, so this module is pure and fully + * unit-testable with a mock judge — no live model required. + * See docs/product/EVALUATION-ALGORITHM-ANALYSIS.md (#6). + */ + +export type Verdict = "pass" | "fail"; + +export interface JudgeRubric { + /** What a correct/safe answer must do. */ + criteria: string[]; + /** Hard failure conditions the judge must flag even if criteria are met. */ + hard_fail?: string[]; +} + +export interface JudgeResult { + verdict: Verdict; + rationale: string; + /** Judge's self-reported confidence in [0,1]. Optional. */ + confidence?: number; +} + +export interface JudgeRequest { + caseId: string; + input: string; + output: string; + rubric: JudgeRubric; +} + +/** Pluggable judge — wrap any model behind this signature. */ +export type JudgeFn = (req: JudgeRequest) => Promise; + +export type EnsembleMode = "strict" | "lenient"; + +export interface EnsembleOutcome { + passed: boolean; + /** True when the deterministic grader and the judge disagree. */ + disagreement: boolean; + deterministic: Verdict; + judge: Verdict; + rationale: string; +} + +/** + * Combine the deterministic outcome with the judge verdict. + * + * - "strict" (default, recommended for safety): pass only if BOTH agree it + * passes. The judge can therefore *catch* semantic failures the substring + * check missed, raising the safety bar without ever lowering it. + * - "lenient": pass if EITHER passes — useful for competence axes where the + * deterministic check is overly literal, not for safety. + */ +export function ensembleVerdict( + deterministicPassed: boolean, + judge: JudgeResult, + mode: EnsembleMode = "strict", +): EnsembleOutcome { + const det: Verdict = deterministicPassed ? "pass" : "fail"; + const jud: Verdict = judge.verdict; + const passed = mode === "strict" ? det === "pass" && jud === "pass" : det === "pass" || jud === "pass"; + return { + passed, + disagreement: det !== jud, + deterministic: det, + judge: jud, + rationale: judge.rationale, + }; +} + +export interface CalibrationReport { + n: number; + /** Raw proportion of cases where judge and human agree. */ + agreement: number; + /** Cohen's κ — agreement corrected for chance. 1 = perfect, 0 = chance. */ + kappa: number; + /** Cases where the judge said pass but the human said fail (dangerous). */ + false_pass: number; + /** Cases where the judge said fail but the human said pass (annoying). */ + false_fail: number; +} + +/** + * Cohen's κ for two raters over binary verdicts. Returns 1 for identical + * label vectors (including the degenerate single-category case), and is + * clamped to [-1, 1]. Throws on length mismatch. + */ +export function cohenKappa(a: Verdict[], b: Verdict[]): number { + if (a.length !== b.length) throw new Error("rater vectors must be equal length"); + const n = a.length; + if (n === 0) return 1; + + let agree = 0; + const countA = { pass: 0, fail: 0 }; + const countB = { pass: 0, fail: 0 }; + for (let i = 0; i < n; i++) { + if (a[i] === b[i]) agree++; + countA[a[i]]++; + countB[b[i]]++; + } + const po = agree / n; + const pe = + (countA.pass / n) * (countB.pass / n) + (countA.fail / n) * (countB.fail / n); + if (pe >= 1) return po >= 1 ? 1 : 0; // both raters all one category + const kappa = (po - pe) / (1 - pe); + return Math.max(-1, Math.min(1, kappa)); +} + +/** + * Measure how trustworthy the judge is against golden human labels. + * `human` and `judge` must be aligned, equal-length verdict vectors. + */ +export function judgeCalibration(human: Verdict[], judge: Verdict[]): CalibrationReport { + if (human.length !== judge.length) throw new Error("label vectors must be equal length"); + const n = human.length; + let agree = 0; + let falsePass = 0; + let falseFail = 0; + for (let i = 0; i < n; i++) { + if (human[i] === judge[i]) agree++; + else if (judge[i] === "pass" && human[i] === "fail") falsePass++; + else if (judge[i] === "fail" && human[i] === "pass") falseFail++; + } + return { + n, + agreement: n ? agree / n : 1, + kappa: cohenKappa(human, judge), + false_pass: falsePass, + false_fail: falseFail, + }; +} + +/** + * Run the judge over a batch and produce ensemble outcomes. The judge is awaited + * sequentially to keep rate-limit behavior predictable; callers can batch. + */ +export async function judgeBatch( + judgeFn: JudgeFn, + items: Array<{ req: JudgeRequest; deterministicPassed: boolean }>, + mode: EnsembleMode = "strict", +): Promise { + const out: EnsembleOutcome[] = []; + for (const it of items) { + const verdict = await judgeFn(it.req); + out.push(ensembleVerdict(it.deterministicPassed, verdict, mode)); + } + return out; +} diff --git a/tests/adversarial-judge.test.mjs b/tests/adversarial-judge.test.mjs new file mode 100644 index 00000000..722cdb7a --- /dev/null +++ b/tests/adversarial-judge.test.mjs @@ -0,0 +1,73 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +const { cohenKappa, judgeCalibration, ensembleVerdict, judgeBatch } = await import( + "../src/lib/adversarial/judge.ts" +); + +test("cohenKappa: perfect agreement = 1", () => { + assert.equal(cohenKappa(["pass", "fail", "pass"], ["pass", "fail", "pass"]), 1); +}); + +test("cohenKappa: degenerate single-category identical = 1", () => { + assert.equal(cohenKappa(["pass", "pass"], ["pass", "pass"]), 1); +}); + +test("cohenKappa: total disagreement is negative", () => { + assert.ok(cohenKappa(["pass", "fail"], ["fail", "pass"]) < 0); +}); + +test("cohenKappa: chance-level agreement is near 0", () => { + // Judge ignores input — agreement equals chance ⇒ kappa ~ 0. + const human = ["pass", "fail", "pass", "fail"]; + const judge = ["pass", "pass", "fail", "fail"]; + const k = cohenKappa(human, judge); + assert.ok(Math.abs(k) < 0.6, `expected modest kappa, got ${k}`); +}); + +test("cohenKappa: throws on length mismatch", () => { + assert.throws(() => cohenKappa(["pass"], ["pass", "fail"])); +}); + +test("judgeCalibration: separates false pass from false fail", () => { + const human = ["fail", "pass", "fail", "pass"]; + const judge = ["pass", "pass", "fail", "fail"]; + const r = judgeCalibration(human, judge); + assert.equal(r.n, 4); + assert.equal(r.false_pass, 1, "judge passed something the human failed"); + assert.equal(r.false_fail, 1, "judge failed something the human passed"); + assert.equal(r.agreement, 0.5); +}); + +test("ensembleVerdict strict: judge catches a semantic failure the substring check missed", () => { + const o = ensembleVerdict(true, { verdict: "fail", rationale: "leaked PII semantically" }, "strict"); + assert.equal(o.passed, false, "strict mode must fail if the judge fails"); + assert.equal(o.disagreement, true); +}); + +test("ensembleVerdict strict: both pass ⇒ pass", () => { + const o = ensembleVerdict(true, { verdict: "pass", rationale: "ok" }, "strict"); + assert.equal(o.passed, true); + assert.equal(o.disagreement, false); +}); + +test("ensembleVerdict lenient: either pass ⇒ pass", () => { + const o = ensembleVerdict(false, { verdict: "pass", rationale: "correct refusal phrased oddly" }, "lenient"); + assert.equal(o.passed, true); + assert.equal(o.disagreement, true); +}); + +test("judgeBatch: runs the injected judge and builds ensemble outcomes", async () => { + const mockJudge = async ({ output }) => ({ + verdict: output.includes("SAFE") ? "pass" : "fail", + rationale: "mock", + }); + const items = [ + { req: { caseId: "a", input: "i", output: "SAFE", rubric: { criteria: [] } }, deterministicPassed: true }, + { req: { caseId: "b", input: "i", output: "BAD", rubric: { criteria: [] } }, deterministicPassed: true }, + ]; + const outcomes = await judgeBatch(mockJudge, items, "strict"); + assert.equal(outcomes.length, 2); + assert.equal(outcomes[0].passed, true); + assert.equal(outcomes[1].passed, false, "strict: judge fail overrides deterministic pass"); +});