Andrey Antukh a16d40b954 🎉 Add semantic file projection RPC and analysis prototype
Add a main RPC command, get-semantic-file, that turns a Penpot file
into a small, neutral projection: pages, frames, text, links and
interactions, with libraries, geometry and assets stripped. The
projection is what a decision model sees, so the extractor stays
model-agnostic and the deterministic checks live outside it.

Add experiments/security-projection, a Node prototype that consumes
the command: analyze.mjs (file -> projection -> checks -> decision
model), compare.mjs (one file, several models), evaluate.mjs with a
labeled set of projections, and seed.mjs, which builds real files to
validate the extractor. It talks to OpenRouter decision models.

Tests: backend_tests.rpc-semantic-test (3 tests, 21 assertions) and
24 Node tests across the prototype.

AI-assisted-by: deepseek-v4.1-flash
2026-10-08 17:40:22 +00:00

139 lines
4.9 KiB
JavaScript

#!/usr/bin/env node
// Evaluate decision models against the labeled projection set.
//
// Reads fixtures/labeled-projections.json, asks every model the same questions
// about each projection, and reports precision / recall / false positives /
// false negatives, plus cost and latency. Entries tagged "ambiguous" are shown
// but excluded from the metrics; entries tagged "adversarial" stay in the
// metrics and can be read apart in the per-case table.
//
// Usage:
// node evaluate.mjs # default models, threshold 0.5
// node evaluate.mjs --threshold 0.7
// node evaluate.mjs typesafe/jev-1.13 cloudflare/clef-flash
//
// Config comes from .env, same as analyze.mjs. Needs no Penpot instance: the
// fixtures carry the projections.
import fs from "node:fs";
import process from "node:process";
import { pathToFileURL } from "node:url";
import { askDecisions, deterministicChecks } from "./analyze.mjs";
const FIXTURES = new URL("./fixtures/labeled-projections.json", import.meta.url);
const DEFAULT_MODELS = [
"typesafe/jev-1.13",
"cloudflare/clef-flash",
"liquid/d1",
"perplexity/pplx-decider-v1.1-27b",
"inception/mercury-decide:free",
];
function parseArgs(argv) {
const args = { threshold: 0.5, models: [] };
for (let i = 0; i < argv.length; i++) {
if (argv[i] === "--threshold") args.threshold = Number(argv[++i]);
else args.models.push(argv[i]);
}
return args;
}
export function median(values) {
if (!values.length) return null;
const sorted = [...values].sort((a, b) => a - b);
return sorted[Math.floor(sorted.length / 2)];
}
export function summarize(rows, threshold) {
let tp = 0, fp = 0, fn = 0, tn = 0;
for (const row of rows) {
const predicted = row.prob >= threshold;
const actual = row.label === "phishing";
if (predicted && actual) tp++;
else if (predicted && !actual) fp++;
else if (!predicted && actual) fn++;
else tn++;
}
const precision = tp + fp ? tp / (tp + fp) : null;
const recall = tp + fn ? tp / (tp + fn) : null;
const f1 = precision && recall ? (2 * precision * recall) / (precision + recall) : null;
return { tp, fp, fn, tn, precision, recall, f1 };
}
const fmt = (value, digits = 3) =>
value === null || value === undefined ? "-" : value.toFixed(digits);
async function main() {
const args = parseArgs(process.argv.slice(2));
const models = args.models.length ? args.models : DEFAULT_MODELS;
const { entries } = JSON.parse(fs.readFileSync(FIXTURES, "utf8"));
const scored = entries.filter((e) => e.tag !== "ambiguous");
const benign = entries.filter((e) => e.label === "benign").length;
console.log(`entries: ${entries.length} (benign ${benign}, phishing ${entries.length - benign})`);
console.log(`ambiguous (excluded from metrics): ${entries.length - scored.length}`);
console.log(`threshold: ${args.threshold}`);
console.log(`models: ${models.join(", ")}\n`);
// results[model][entryId] = { prob, ms, cost } | { error }
const results = {};
for (const model of models) {
results[model] = {};
for (const entry of entries) {
const started = Date.now();
try {
const body = await askDecisions(entry.projection, deterministicChecks(entry.projection), model);
results[model][entry.id] = {
prob: body.answers?.is_phishing?.noul ?? null,
ms: Date.now() - started,
cost: body.usage?.cost ?? 0,
};
} catch (err) {
results[model][entry.id] = { error: err.message };
}
}
process.stderr.write(` ${model} done\n`);
}
console.log("model\tthr\tTP\tFP\tFN\tTN\tprecision\trecall\tf1\tp50ms\tcost$");
for (const model of models) {
const rows = scored
.map((entry) => ({ label: entry.label, prob: results[model][entry.id]?.prob }))
.filter((row) => typeof row.prob === "number");
const m = summarize(rows, args.threshold);
const times = Object.values(results[model]).map((r) => r.ms).filter(Boolean);
const cost = Object.values(results[model]).reduce((acc, r) => acc + (r.cost ?? 0), 0);
console.log([
model,
args.threshold,
m.tp, m.fp, m.fn, m.tn,
fmt(m.precision, 2), fmt(m.recall, 2), fmt(m.f1, 2),
median(times) ?? "-",
cost.toFixed(6),
].join("\t"));
}
const errors = models.flatMap((model) =>
Object.entries(results[model]).filter(([, r]) => r.error).map(([id, r]) => `${model} ${id}: ${r.error}`));
if (errors.length) {
console.log("\nerrors:");
for (const line of errors) console.log(` ${line}`);
}
console.log("\nper-case is_phishing probability:");
console.log(["case", "label", "tag", ...models].join("\t"));
for (const entry of entries) {
const cells = models.map((model) => fmt(results[model][entry.id]?.prob));
console.log([entry.id, entry.label, entry.tag, ...cells].join("\t"));
}
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
main().catch((err) => {
console.error(`error: ${err.stack ?? String(err)}`);
process.exit(1);
});
}