Prepare scanner branch for merge
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
param(
|
||||
[string]$ProjectRoot
|
||||
[string]$ProjectRoot,
|
||||
[string]$OcrWorkers = ""
|
||||
)
|
||||
|
||||
# Mit -NoExit gestartet: dieses Fenster bleibt immer offen (siehe dev-admin.cmd),
|
||||
@@ -20,6 +21,10 @@ try {
|
||||
|
||||
Write-Host "Projekt: $project"
|
||||
Write-Host "Admin-Log: $logPath"
|
||||
if ($OcrWorkers) {
|
||||
$env:GAA_OCR_WORKERS = $OcrWorkers
|
||||
Write-Host "GAA_OCR_WORKERS: $env:GAA_OCR_WORKERS"
|
||||
}
|
||||
|
||||
# A UAC-elevated process gets its environment rebuilt fresh from the
|
||||
# registry; it does NOT inherit PATH edits that only exist in the calling
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
param(
|
||||
[string]$ProjectRoot = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot "..")).Path
|
||||
[string]$ProjectRoot = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot "..")).Path,
|
||||
[string]$OcrWorkers = $env:GAA_OCR_WORKERS
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
@@ -19,6 +20,9 @@ try {
|
||||
"-File $(Quote-ProcessArgument $script)",
|
||||
"-ProjectRoot $(Quote-ProcessArgument $project)"
|
||||
) -join " "
|
||||
if ($OcrWorkers) {
|
||||
$arguments += " -OcrWorkers $(Quote-ProcessArgument $OcrWorkers)"
|
||||
}
|
||||
|
||||
Start-Process -FilePath $powershellExe -ArgumentList $arguments -WorkingDirectory $project -Verb RunAs -WindowStyle Normal -ErrorAction Stop
|
||||
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const readline = require("node:readline");
|
||||
const os = require("node:os");
|
||||
|
||||
const DEFAULT_LIMIT = 80;
|
||||
|
||||
function argValue(name, fallback = "") {
|
||||
const prefix = `--${name}=`;
|
||||
const match = process.argv.find((entry) => entry.startsWith(prefix));
|
||||
return match ? match.slice(prefix.length) : fallback;
|
||||
}
|
||||
|
||||
function defaultReviewSamplesPath() {
|
||||
const appData = process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming");
|
||||
return path.join(appData, "genshin-artifact-assistant", "review-samples.jsonl");
|
||||
}
|
||||
|
||||
function simplifyId(value) {
|
||||
return String(value || "")
|
||||
.replace(/[^0-9A-Za-z]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 80) || "unknown";
|
||||
}
|
||||
|
||||
function parsedSummary(parsed) {
|
||||
if (!parsed || typeof parsed !== "object") return {};
|
||||
return {
|
||||
name: parsed.name,
|
||||
slot: parsed.slot,
|
||||
level: parsed.level,
|
||||
mainStat: parsed.mainStat,
|
||||
mainValue: parsed.mainValue,
|
||||
setName: parsed.setName,
|
||||
equipped: parsed.equipped,
|
||||
substats: Array.isArray(parsed.substats) ? parsed.substats : [],
|
||||
confidence: parsed.confidence,
|
||||
notes: Array.isArray(parsed.notes) ? parsed.notes : [],
|
||||
};
|
||||
}
|
||||
|
||||
const REQUIRED_FAST_OCR_FIELDS = [
|
||||
"artifact-name",
|
||||
"artifact-slot",
|
||||
"artifact-main-stat-label",
|
||||
"artifact-level",
|
||||
"artifact-substats",
|
||||
];
|
||||
|
||||
function ocrMap(record) {
|
||||
const entries = record?.sample?.capture?.ocr;
|
||||
if (!Array.isArray(entries) || entries.length === 0) return null;
|
||||
const result = {};
|
||||
for (const entry of entries) {
|
||||
if (typeof entry?.id === "string" && typeof entry?.text === "string") {
|
||||
result[entry.id] = entry.text;
|
||||
}
|
||||
}
|
||||
return Object.keys(result).length ? result : null;
|
||||
}
|
||||
|
||||
function candidateFromRecord(record, index) {
|
||||
const ocr = ocrMap(record);
|
||||
if (!ocr) return null;
|
||||
const parsed = parsedSummary(record?.sample?.parsed);
|
||||
const reason = record?.sample?.reason || "missing-reason";
|
||||
const savedAt = record?.savedAt || "unknown";
|
||||
const capture = record?.sample?.capture || {};
|
||||
const ocrFieldIds = Object.keys(ocr).sort();
|
||||
const missingFastFields = REQUIRED_FAST_OCR_FIELDS.filter((field) => !ocr[field]);
|
||||
const hasEquippedFooterOcr = Boolean(ocr["artifact-footer"]);
|
||||
const locked = typeof capture.locked === "boolean" ? capture.locked : null;
|
||||
return {
|
||||
id: `review-${simplifyId(savedAt)}-${index}`,
|
||||
savedAt,
|
||||
reason,
|
||||
confirmed: false,
|
||||
resolution: capture.width && capture.height ? `${capture.width}x${capture.height}` : "",
|
||||
ocrFieldIds,
|
||||
missingFastFields,
|
||||
hasEquippedFooterOcr,
|
||||
locked,
|
||||
likelyStaleCapture: missingFastFields.includes("artifact-slot"),
|
||||
parsed,
|
||||
ocr,
|
||||
reviewPrompt: {
|
||||
action: "Confirm or correct parsed fields before moving this case into src/eval/corpus/confirmedReviewCorpus.ts.",
|
||||
expectedFields: {
|
||||
name: parsed.name || "",
|
||||
slot: parsed.slot || "",
|
||||
level: parsed.level ?? "",
|
||||
mainStat: parsed.mainStat || "",
|
||||
mainValue: parsed.mainValue || "",
|
||||
setName: parsed.setName || "",
|
||||
equipped: parsed.equipped || "",
|
||||
substats: parsed.substats || [],
|
||||
},
|
||||
validationChecks: {
|
||||
equipped: hasEquippedFooterOcr ? "Confirm character name or mark Not detected." : "No footer OCR in this sample.",
|
||||
locked: locked === null ? "No lock-state payload in this sample." : `Confirm locked=${locked}.`,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function candidateKey(candidate) {
|
||||
return [
|
||||
candidate.reason,
|
||||
candidate.parsed.name,
|
||||
candidate.parsed.slot,
|
||||
candidate.parsed.mainStat,
|
||||
candidate.parsed.setName,
|
||||
JSON.stringify(candidate.parsed.substats || []),
|
||||
JSON.stringify(candidate.ocr),
|
||||
].join("\u001f");
|
||||
}
|
||||
|
||||
function candidatePriority(candidate) {
|
||||
const reason = candidate.reason || "";
|
||||
const parsed = candidate.parsed || {};
|
||||
const missingCount = candidate.missingFastFields?.length || 0;
|
||||
const hasUnknownCritical = [parsed.name, parsed.slot, parsed.mainStat, parsed.setName].some((value) => String(value || "").startsWith("Unknown"));
|
||||
if (candidate.likelyStaleCapture) return 4;
|
||||
if (missingCount === 0 && /low-field|low-total|capture-rejected|initial-selection/i.test(reason)) return 0;
|
||||
if (missingCount === 0 && (parsed.notes || []).some((note) => /fuzzy|incomplete|not confidently|low/i.test(note))) return 1;
|
||||
if (missingCount === 0) return 2;
|
||||
if (missingCount <= 1 && !hasUnknownCritical) return 3;
|
||||
return 5;
|
||||
}
|
||||
|
||||
async function readCandidates(inputPath, limit) {
|
||||
const candidates = [];
|
||||
const seen = new Set();
|
||||
let total = 0;
|
||||
let invalid = 0;
|
||||
const rl = readline.createInterface({ input: fs.createReadStream(inputPath, { encoding: "utf8" }) });
|
||||
for await (const line of rl) {
|
||||
if (!line.trim()) continue;
|
||||
total++;
|
||||
let record;
|
||||
try {
|
||||
record = JSON.parse(line);
|
||||
} catch {
|
||||
invalid++;
|
||||
continue;
|
||||
}
|
||||
const candidate = candidateFromRecord(record, total);
|
||||
if (!candidate) continue;
|
||||
const key = candidateKey(candidate);
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
candidates.push(candidate);
|
||||
}
|
||||
candidates.sort((left, right) => {
|
||||
const priority = candidatePriority(left) - candidatePriority(right);
|
||||
if (priority) return priority;
|
||||
const missingDiff = (left.missingFastFields?.length || 0) - (right.missingFastFields?.length || 0);
|
||||
if (missingDiff) return missingDiff;
|
||||
return String(right.savedAt).localeCompare(String(left.savedAt));
|
||||
});
|
||||
return { total, invalid, candidates: candidates.slice(0, limit), uniqueCandidates: candidates.length };
|
||||
}
|
||||
|
||||
function increment(map, key) {
|
||||
const normalizedKey = key || "unknown";
|
||||
map[normalizedKey] = (map[normalizedKey] || 0) + 1;
|
||||
}
|
||||
|
||||
function buildExportStats(candidates) {
|
||||
const reasonCounts = {};
|
||||
const missingFastFieldCounts = {};
|
||||
let completeFastFields = 0;
|
||||
let likelyStaleCaptures = 0;
|
||||
let equippedFooterCandidates = 0;
|
||||
let lockedTrueCandidates = 0;
|
||||
let lockedFalseCandidates = 0;
|
||||
for (const candidate of candidates) {
|
||||
increment(reasonCounts, candidate.reason);
|
||||
if (candidate.likelyStaleCapture) likelyStaleCaptures++;
|
||||
if (candidate.hasEquippedFooterOcr) equippedFooterCandidates++;
|
||||
if (candidate.locked === true) lockedTrueCandidates++;
|
||||
if (candidate.locked === false) lockedFalseCandidates++;
|
||||
const missingFields = candidate.missingFastFields || [];
|
||||
if (missingFields.length === 0) completeFastFields++;
|
||||
for (const field of missingFields) increment(missingFastFieldCounts, field);
|
||||
}
|
||||
return {
|
||||
completeFastFields,
|
||||
likelyStaleCaptures,
|
||||
equippedFooterCandidates,
|
||||
lockedTrueCandidates,
|
||||
lockedFalseCandidates,
|
||||
reasonCounts,
|
||||
missingFastFieldCounts,
|
||||
};
|
||||
}
|
||||
|
||||
function markdownFor(summary, candidates) {
|
||||
const lines = [
|
||||
"# Review Eval Candidates",
|
||||
"",
|
||||
`Source: ${summary.inputPath}`,
|
||||
`Generated: ${summary.generatedAt}`,
|
||||
`Records read: ${summary.recordsRead}`,
|
||||
`Unique candidates: ${summary.uniqueCandidates}`,
|
||||
`Exported candidates: ${summary.exportedCandidates}`,
|
||||
`Complete fast-field candidates: ${summary.exportStats.completeFastFields}`,
|
||||
`Likely stale captures: ${summary.exportStats.likelyStaleCaptures}`,
|
||||
`Equipped footer candidates: ${summary.exportStats.equippedFooterCandidates}`,
|
||||
`Locked=true candidates: ${summary.exportStats.lockedTrueCandidates}`,
|
||||
`Locked=false candidates: ${summary.exportStats.lockedFalseCandidates}`,
|
||||
"",
|
||||
"These cases are not ground truth yet. Confirm or correct the expected fields before committing any case into `src/eval/corpus/`.",
|
||||
"",
|
||||
"## Export stats",
|
||||
"",
|
||||
"Reason counts:",
|
||||
"",
|
||||
...Object.entries(summary.exportStats.reasonCounts).map(([reason, count]) => `- ${reason}: ${count}`),
|
||||
"",
|
||||
"Missing fast-field counts:",
|
||||
"",
|
||||
...Object.entries(summary.exportStats.missingFastFieldCounts).map(([field, count]) => `- ${field}: ${count}`),
|
||||
"",
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
lines.push(`## ${candidate.id}`);
|
||||
lines.push("");
|
||||
lines.push(`- savedAt: ${candidate.savedAt}`);
|
||||
lines.push(`- reason: ${candidate.reason}`);
|
||||
lines.push(`- missingFastFields: ${candidate.missingFastFields.join(", ") || "none"}`);
|
||||
lines.push(`- likelyStaleCapture: ${candidate.likelyStaleCapture ? "yes" : "no"}`);
|
||||
lines.push(`- hasEquippedFooterOcr: ${candidate.hasEquippedFooterOcr ? "yes" : "no"}`);
|
||||
lines.push(`- locked: ${candidate.locked === null ? "unknown" : candidate.locked}`);
|
||||
lines.push(`- parsed: ${candidate.parsed.name || "?"} | ${candidate.parsed.slot || "?"} | ${candidate.parsed.mainStat || "?"} | ${candidate.parsed.setName || "?"} | equipped=${candidate.parsed.equipped || "?"}`);
|
||||
lines.push(`- substats: ${(candidate.parsed.substats || []).join(", ") || "?"}`);
|
||||
lines.push("");
|
||||
lines.push("OCR:");
|
||||
for (const [id, text] of Object.entries(candidate.ocr)) {
|
||||
lines.push(`- ${id}: ${JSON.stringify(text)}`);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
return `${lines.join("\n")}\n`;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const inputPath = path.resolve(argValue("input", defaultReviewSamplesPath()));
|
||||
const outputDir = path.resolve(argValue("out", path.join(process.cwd(), "outputs", "review-eval-candidates")));
|
||||
const limit = Math.max(1, Math.min(500, Number(argValue("limit", String(DEFAULT_LIMIT))) || DEFAULT_LIMIT));
|
||||
if (!fs.existsSync(inputPath)) {
|
||||
throw new Error(`Review sample file not found: ${inputPath}`);
|
||||
}
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
const result = await readCandidates(inputPath, limit);
|
||||
const summary = {
|
||||
inputPath,
|
||||
outputDir,
|
||||
generatedAt: new Date().toISOString(),
|
||||
recordsRead: result.total,
|
||||
invalidRecords: result.invalid,
|
||||
uniqueCandidates: result.uniqueCandidates,
|
||||
exportedCandidates: result.candidates.length,
|
||||
exportStats: buildExportStats(result.candidates),
|
||||
};
|
||||
const payload = { summary, candidates: result.candidates };
|
||||
const jsonPath = path.join(outputDir, "review-eval-candidates.json");
|
||||
const mdPath = path.join(outputDir, "review-eval-candidates.md");
|
||||
fs.writeFileSync(jsonPath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
|
||||
fs.writeFileSync(mdPath, markdownFor(summary, result.candidates), "utf8");
|
||||
console.log(JSON.stringify({ ok: true, ...summary, jsonPath, mdPath }, null, 2));
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildExportStats,
|
||||
candidateFromRecord,
|
||||
candidatePriority,
|
||||
defaultReviewSamplesPath,
|
||||
markdownFor,
|
||||
readCandidates,
|
||||
};
|
||||
@@ -0,0 +1,148 @@
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
function argValue(name, fallback = "") {
|
||||
const prefix = `--${name}=`;
|
||||
const match = process.argv.find((entry) => entry.startsWith(prefix));
|
||||
return match ? match.slice(prefix.length) : fallback;
|
||||
}
|
||||
|
||||
function hasFlag(name) {
|
||||
return process.argv.includes(`--${name}`);
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function parseWaitSeconds(value) {
|
||||
if (value === "") return 0;
|
||||
const parsed = Number(value);
|
||||
if (!Number.isInteger(parsed) || parsed < 0) {
|
||||
throw new Error("--wait must be a non-negative integer number of seconds.");
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function expectedSignature() {
|
||||
const mainPath = path.join(process.cwd(), "electron", "main.ts");
|
||||
const source = fs.readFileSync(mainPath, "utf8");
|
||||
const match = source.match(/APP_RUNTIME_SIGNATURE\s*=\s*"([^"]+)"/);
|
||||
return match?.[1] || "";
|
||||
}
|
||||
|
||||
async function fetchJson(baseUrl, endpoint) {
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(`${baseUrl}${endpoint}`);
|
||||
} catch (error) {
|
||||
throw new Error(`Could not reach ${baseUrl}${endpoint}. Start the elevated app with npm run dev:admin and confirm UAC before running live scans. (${error instanceof Error ? error.message : String(error)})`);
|
||||
}
|
||||
const text = await response.text();
|
||||
let payload;
|
||||
try {
|
||||
payload = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
throw new Error(`${endpoint} returned non-JSON response (${response.status}).`);
|
||||
}
|
||||
if (!response.ok) throw new Error(`${endpoint} returned ${response.status}: ${JSON.stringify(payload)}`);
|
||||
return payload;
|
||||
}
|
||||
|
||||
function validatePreflight({ health, status, expected, requireElevated = true, requireGenshin = true }) {
|
||||
const errors = [];
|
||||
const appBuild = health?.appBuild;
|
||||
const runtime = status?.status?.runtimeInfo;
|
||||
|
||||
if (!appBuild?.signature) {
|
||||
errors.push("/health is missing appBuild.signature.");
|
||||
} else if (expected && appBuild.signature !== expected) {
|
||||
errors.push(`Runtime signature '${appBuild.signature}' does not match source '${expected}'.`);
|
||||
}
|
||||
|
||||
if (!status?.status) errors.push("/scanner/status is missing status payload.");
|
||||
if (!runtime) {
|
||||
errors.push("/scanner/status is missing runtimeInfo. Open the scanner view and restart the elevated app if needed.");
|
||||
} else {
|
||||
if (requireElevated && runtime.isElevated !== true) errors.push("Runtime is not elevated.");
|
||||
if (requireGenshin && runtime.genshinFound !== true) errors.push("Genshin process/window was not found.");
|
||||
}
|
||||
|
||||
return {
|
||||
ok: errors.length === 0,
|
||||
errors,
|
||||
signature: appBuild?.signature || "",
|
||||
expectedSignature: expected || "",
|
||||
isElevated: runtime?.isElevated,
|
||||
genshinFound: runtime?.genshinFound,
|
||||
targetProcess: runtime?.targetProcess || "",
|
||||
foregroundProcess: runtime?.foregroundProcess || "",
|
||||
};
|
||||
}
|
||||
|
||||
function formatSummary(result) {
|
||||
const lines = [
|
||||
`live preflight: ${result.ok ? "PASS" : "FAIL"}`,
|
||||
`signature: ${result.signature || "missing"}`,
|
||||
`expected: ${result.expectedSignature || "unknown"}`,
|
||||
`elevated: ${result.isElevated === true ? "yes" : result.isElevated === false ? "no" : "unknown"}`,
|
||||
`genshin: ${result.genshinFound === true ? "yes" : result.genshinFound === false ? "no" : "unknown"}`,
|
||||
`target: ${result.targetProcess || "unknown"}`,
|
||||
`foreground: ${result.foregroundProcess || "unknown"}`,
|
||||
];
|
||||
if (!result.ok) {
|
||||
lines.push("errors:");
|
||||
for (const error of result.errors) lines.push(`- ${error}`);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
async function runPreflight({ baseUrl, expected, requireElevated, requireGenshin }) {
|
||||
const health = await fetchJson(baseUrl, "/health");
|
||||
const status = await fetchJson(baseUrl, "/scanner/status");
|
||||
return validatePreflight({ health, status, expected, requireElevated, requireGenshin });
|
||||
}
|
||||
|
||||
async function waitForPreflight(options, waitSeconds) {
|
||||
const deadline = Date.now() + waitSeconds * 1000;
|
||||
let lastError = null;
|
||||
let lastResult = null;
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
const result = await runPreflight(options);
|
||||
lastResult = result;
|
||||
if (result.ok || Date.now() >= deadline) return result;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (Date.now() >= deadline) throw lastError;
|
||||
}
|
||||
await sleep(1000);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const baseUrl = argValue("base-url", "http://127.0.0.1:17317").replace(/\/$/, "");
|
||||
const expected = argValue("expected-signature", expectedSignature());
|
||||
const requireElevated = !hasFlag("allow-standard");
|
||||
const requireGenshin = !hasFlag("allow-missing-genshin");
|
||||
const waitSeconds = parseWaitSeconds(argValue("wait", ""));
|
||||
const options = { baseUrl, expected, requireElevated, requireGenshin };
|
||||
const result = waitSeconds > 0 ? await waitForPreflight(options, waitSeconds) : await runPreflight(options);
|
||||
console.log(hasFlag("json") ? JSON.stringify(result, null, 2) : formatSummary(result));
|
||||
if (!result.ok) process.exit(1);
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
formatSummary,
|
||||
parseWaitSeconds,
|
||||
runPreflight,
|
||||
validatePreflight,
|
||||
};
|
||||
+108
-3
@@ -77,6 +77,7 @@ function Get-ScannerStatus {
|
||||
function Test-ProbeSucceeded([object]$ProbePayload) {
|
||||
if ($ProbePayload.ok) { return $true }
|
||||
if ($ProbePayload.changed) { return $true }
|
||||
if ($ProbePayload.click -and $ProbePayload.click.clicked -and $ProbePayload.click.moved -and -not $ProbePayload.click.inputBlocked) { return $true }
|
||||
return $false
|
||||
}
|
||||
|
||||
@@ -282,6 +283,9 @@ function New-PerformanceAssessment([object[]]$Summaries) {
|
||||
|
||||
$limitReports += [pscustomobject]@{
|
||||
limit = [int]$group.Name
|
||||
engineCount = $entries.Count
|
||||
enginesCompared = @($entries | ForEach-Object { $_.engine })
|
||||
comparisonComplete = (@($entries | Where-Object { $_.engine -eq "current" }).Count -gt 0 -and @($entries | Where-Object { $_.engine -eq "ik-traineddata" }).Count -gt 0)
|
||||
winnerEngine = $winner.engine
|
||||
winnerQualified = $winner.qualified
|
||||
winnerMissRate = $winner.missRate
|
||||
@@ -293,19 +297,37 @@ function New-PerformanceAssessment([object[]]$Summaries) {
|
||||
}
|
||||
|
||||
$goal100 = @($limitReports | Where-Object { $_.limit -eq 100 } | Select-Object -First 1)
|
||||
$goal100Decision = "not-run: missing 100-artifact assessment"
|
||||
if ($goal100.Count -gt 0) {
|
||||
if (-not $goal100[0].comparisonComplete) {
|
||||
$goal100Decision = "not-comparable: current and ik-traineddata were not both run"
|
||||
} elseif (-not $goal100[0].winnerQualified) {
|
||||
$goal100Decision = "not-qualified: 100-artifact winner failed quality gates"
|
||||
} else {
|
||||
$goal100Decision = "qualified-comparison: winner=$($goal100[0].winnerEngine)"
|
||||
}
|
||||
}
|
||||
return [pscustomobject]@{
|
||||
createdAt = (Get-Date).ToString("o")
|
||||
goalLimit = 100
|
||||
goalEngines = @("current", "ik-traineddata")
|
||||
goal100Decision = $goal100Decision
|
||||
goal100 = if ($goal100.Count -gt 0) { $goal100[0] } else { $null }
|
||||
limits = $limitReports
|
||||
}
|
||||
}
|
||||
|
||||
function Write-PerformanceAssessment([object]$Assessment) {
|
||||
if ($Assessment.goal100Decision) {
|
||||
Write-Host "assessment goal100: $($Assessment.goal100Decision)"
|
||||
}
|
||||
foreach ($limit in @($Assessment.limits)) {
|
||||
Write-Host ("assessment limit={0}: winner={1} qualified={2} missRate={3:P1} reviewRate={4:P1} activeAvg={5}ms projected100={6}ms" -f `
|
||||
Write-Host ("assessment limit={0}: winner={1} qualified={2} completeCompare={3} engines={4} missRate={5:P1} reviewRate={6:P1} activeAvg={7}ms projected100={8}ms" -f `
|
||||
$limit.limit,
|
||||
$limit.winnerEngine,
|
||||
$limit.winnerQualified,
|
||||
$limit.comparisonComplete,
|
||||
($limit.enginesCompared -join ","),
|
||||
$limit.winnerMissRate,
|
||||
$limit.winnerReviewRate,
|
||||
$limit.winnerActiveAverageMsPerParsed,
|
||||
@@ -359,6 +381,36 @@ function Invoke-AssessmentSelfTest {
|
||||
averageCardReadyMs = 205
|
||||
averageScrollReadyMs = 80
|
||||
},
|
||||
[pscustomobject]@{
|
||||
engine = "current"
|
||||
limit = 20
|
||||
status = "done"
|
||||
parsed = 20
|
||||
review = 1
|
||||
misses = 0
|
||||
activeAverageMsPerParsed = 390
|
||||
averageMsPerParsed = 405
|
||||
activeProjectedMsFor100 = 39000
|
||||
averageOcrMs = 150
|
||||
averageCaptureMs = 130
|
||||
averageCardReadyMs = 80
|
||||
averageScrollReadyMs = 0
|
||||
},
|
||||
[pscustomobject]@{
|
||||
engine = "ik-traineddata"
|
||||
limit = 20
|
||||
status = "done"
|
||||
parsed = 20
|
||||
review = 2
|
||||
misses = 0
|
||||
activeAverageMsPerParsed = 460
|
||||
averageMsPerParsed = 475
|
||||
activeProjectedMsFor100 = 46000
|
||||
averageOcrMs = 190
|
||||
averageCaptureMs = 130
|
||||
averageCardReadyMs = 80
|
||||
averageScrollReadyMs = 0
|
||||
},
|
||||
[pscustomobject]@{
|
||||
engine = "broken-fast"
|
||||
limit = 45
|
||||
@@ -393,6 +445,7 @@ function Invoke-AssessmentSelfTest {
|
||||
|
||||
$assessment = New-PerformanceAssessment -Summaries $synthetic
|
||||
$goal100 = $assessment.goal100
|
||||
$limit20 = @($assessment.limits | Where-Object { $_.limit -eq 20 } | Select-Object -First 1)[0]
|
||||
$limit45 = @($assessment.limits | Where-Object { $_.limit -eq 45 } | Select-Object -First 1)[0]
|
||||
|
||||
if ($goal100.winnerEngine -ne "ik-traineddata") {
|
||||
@@ -401,6 +454,21 @@ function Invoke-AssessmentSelfTest {
|
||||
if (-not $goal100.winnerQualified) {
|
||||
throw "Assessment self-test failed: expected limit=100 winner to be qualified."
|
||||
}
|
||||
if (-not $goal100.comparisonComplete) {
|
||||
throw "Assessment self-test failed: expected limit=100 to be a complete current vs ik-traineddata comparison."
|
||||
}
|
||||
if ($assessment.goal100Decision -ne "qualified-comparison: winner=ik-traineddata") {
|
||||
throw "Assessment self-test failed: unexpected goal100Decision '$($assessment.goal100Decision)'."
|
||||
}
|
||||
if ($limit20.winnerEngine -ne "current") {
|
||||
throw "Assessment self-test failed: expected current to win limit=20, got '$($limit20.winnerEngine)'."
|
||||
}
|
||||
if (-not $limit20.comparisonComplete) {
|
||||
throw "Assessment self-test failed: expected limit=20 to be a complete current vs ik-traineddata comparison."
|
||||
}
|
||||
if (-not $limit20.winnerQualified) {
|
||||
throw "Assessment self-test failed: expected limit=20 winner to be qualified."
|
||||
}
|
||||
if ($limit45.winnerEngine -ne "current") {
|
||||
throw "Assessment self-test failed: expected current to win limit=45, got '$($limit45.winnerEngine)'."
|
||||
}
|
||||
@@ -411,6 +479,27 @@ function Invoke-AssessmentSelfTest {
|
||||
throw "Assessment self-test failed: expected broken-fast run to be rejected for miss rate."
|
||||
}
|
||||
|
||||
$singleEngineAssessment = New-PerformanceAssessment -Summaries @(
|
||||
[pscustomobject]@{
|
||||
engine = "current"
|
||||
limit = 100
|
||||
status = "done"
|
||||
parsed = 100
|
||||
review = 0
|
||||
misses = 0
|
||||
activeAverageMsPerParsed = 500
|
||||
averageMsPerParsed = 520
|
||||
activeProjectedMsFor100 = 50000
|
||||
averageOcrMs = 180
|
||||
averageCaptureMs = 120
|
||||
averageCardReadyMs = 100
|
||||
averageScrollReadyMs = 50
|
||||
}
|
||||
)
|
||||
if ($singleEngineAssessment.goal100Decision -ne "not-comparable: current and ik-traineddata were not both run") {
|
||||
throw "Assessment self-test failed: expected single-engine 100 run to be not-comparable, got '$($singleEngineAssessment.goal100Decision)'."
|
||||
}
|
||||
|
||||
Write-PerformanceAssessment $assessment
|
||||
Write-Host "Assessment self-test passed." -ForegroundColor Green
|
||||
return $assessment
|
||||
@@ -527,6 +616,7 @@ function Wait-ForScannerIdle([int]$Limit, [string]$Engine) {
|
||||
$startedAt = Get-Date
|
||||
$pollIndex = 0
|
||||
$lastStatus = $null
|
||||
$observedMatchingRun = $false
|
||||
|
||||
while ($true) {
|
||||
Start-Sleep -Seconds $PollIntervalSeconds
|
||||
@@ -536,8 +626,21 @@ function Wait-ForScannerIdle([int]$Limit, [string]$Engine) {
|
||||
Save-Json "scan-$Engine-limit-$Limit-poll-$pollIndex" $statusPayload | Out-Null
|
||||
|
||||
$running = [bool]$statusPayload.status.running
|
||||
$summaryTarget = if ($statusPayload.status.summary) { [int]$statusPayload.status.summary.targetCount } else { 0 }
|
||||
$scanStart = @($statusPayload.status.diagnosticEvents | Where-Object { $_.phase -eq "scan-start" } | Select-Object -Last 1)
|
||||
$scanStartDetails = if ($scanStart.Count -gt 0) { $scanStart[0].details } else { $null }
|
||||
$scanStartLimit = if ($scanStartDetails -and $scanStartDetails.scanLimit) { [int]$scanStartDetails.scanLimit } else { 0 }
|
||||
$scanStartEngine = if ($scanStartDetails -and $scanStartDetails.ocrEngine) { [string]$scanStartDetails.ocrEngine } else { "" }
|
||||
$matchesScanStart = $scanStartLimit -eq $Limit -and ($scanStartEngine -eq "" -or $scanStartEngine -eq $Engine)
|
||||
$matchesFinalSummary = $summaryTarget -eq $Limit
|
||||
if ($running -and ($matchesScanStart -or $matchesFinalSummary)) {
|
||||
$observedMatchingRun = $true
|
||||
}
|
||||
if (-not $running) {
|
||||
return $statusPayload
|
||||
if ($observedMatchingRun -or $matchesScanStart -or $matchesFinalSummary) {
|
||||
return $statusPayload
|
||||
}
|
||||
Write-Host "Waiting for scanner run limit=$Limit engine=$Engine to appear; ignoring unrelated idle status." -ForegroundColor DarkGray
|
||||
}
|
||||
|
||||
$elapsed = ((Get-Date) - $startedAt).TotalSeconds
|
||||
@@ -619,6 +722,8 @@ try {
|
||||
}
|
||||
} elseif (-not $probe.ok -and $probe.changed) {
|
||||
Write-Host "Probe index=$index changed the detail panel even though helper cursor/click readback was not clean; continuing." -ForegroundColor Yellow
|
||||
} elseif (-not $probe.ok -and $probe.click -and $probe.click.clicked -and $probe.click.moved -and -not $probe.click.inputBlocked) {
|
||||
Write-Host "Probe index=$index delivered input but detail did not change; continuing because the target may already be selected." -ForegroundColor Yellow
|
||||
}
|
||||
}
|
||||
|
||||
@@ -626,7 +731,7 @@ try {
|
||||
foreach ($limit in $Limits) {
|
||||
if ($limit -lt 1) { continue }
|
||||
Write-Host "Starting bounded scanner run limit=$limit engine=$engine"
|
||||
$start = Invoke-DevJson "/scanner/start?limit=$limit&engine=$engine"
|
||||
$start = Invoke-DevJson "/scanner/start?entry=visible-inventory&limit=$limit&engine=$engine"
|
||||
Save-Json "scan-$engine-limit-$limit-start" $start | Out-Null
|
||||
|
||||
$finalStatus = Wait-ForScannerIdle -Limit $limit -Engine $engine
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const EVAL_FIELDS = new Set(["name", "slot", "level", "mainStat", "mainValue", "setName", "equipped", "substats"]);
|
||||
|
||||
function argValue(name, fallback = "") {
|
||||
const prefix = `--${name}=`;
|
||||
const match = process.argv.find((entry) => entry.startsWith(prefix));
|
||||
return match ? match.slice(prefix.length) : fallback;
|
||||
}
|
||||
|
||||
function parseExpectedFields() {
|
||||
const inlineJson = argValue("expect-json");
|
||||
const expectFile = argValue("expect-file");
|
||||
if (!inlineJson && !expectFile) {
|
||||
throw new Error("Missing expected labels. Pass --expect-json=... or --expect-file=...");
|
||||
}
|
||||
const raw = (inlineJson || fs.readFileSync(path.resolve(expectFile), "utf8")).replace(/^\uFEFF/, "");
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
throw new Error("Expected labels must be a JSON object.");
|
||||
}
|
||||
const expect = {};
|
||||
for (const [field, value] of Object.entries(parsed)) {
|
||||
if (!EVAL_FIELDS.has(field)) throw new Error(`Unknown expected field: ${field}`);
|
||||
if (field === "level") {
|
||||
if (!Number.isInteger(value) || value < 0) throw new Error("Expected level must be a non-negative integer.");
|
||||
expect[field] = value;
|
||||
continue;
|
||||
}
|
||||
if (field === "substats") {
|
||||
if (!Array.isArray(value) || !value.every((entry) => typeof entry === "string" && entry.trim())) {
|
||||
throw new Error("Expected substats must be a non-empty string array.");
|
||||
}
|
||||
expect[field] = value;
|
||||
continue;
|
||||
}
|
||||
if (typeof value !== "string" || !value.trim()) throw new Error(`Expected ${field} must be a non-empty string.`);
|
||||
expect[field] = value;
|
||||
}
|
||||
if (Object.keys(expect).length === 0) throw new Error("Expected labels must include at least one field.");
|
||||
return expect;
|
||||
}
|
||||
|
||||
function loadCandidate(inputPath, candidateId) {
|
||||
const payload = JSON.parse(fs.readFileSync(path.resolve(inputPath), "utf8"));
|
||||
const candidates = Array.isArray(payload?.candidates) ? payload.candidates : [];
|
||||
const candidate = candidates.find((entry) => entry.id === candidateId);
|
||||
if (!candidate) throw new Error(`Candidate not found: ${candidateId}`);
|
||||
if (!candidate.ocr || typeof candidate.ocr !== "object" || Object.keys(candidate.ocr).length === 0) {
|
||||
throw new Error(`Candidate has no OCR payload: ${candidateId}`);
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
function stableId(value) {
|
||||
return String(value || "")
|
||||
.replace(/[^0-9A-Za-z]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 80) || "review-case";
|
||||
}
|
||||
|
||||
function objectLiteral(value, indent = 2) {
|
||||
return JSON.stringify(value, null, indent).replace(/"([A-Za-z_$][0-9A-Za-z_$]*)":/g, "$1:");
|
||||
}
|
||||
|
||||
function snippetFor(candidate, expect) {
|
||||
const id = stableId(argValue("id", `confirmed-${candidate.id}`));
|
||||
const entry = {
|
||||
id,
|
||||
confirmed: true,
|
||||
ocr: candidate.ocr,
|
||||
expect,
|
||||
meta: {
|
||||
source: "review-sample",
|
||||
resolution: candidate.resolution || undefined,
|
||||
note: `${candidate.reason || "review-sample"} | savedAt=${candidate.savedAt || "unknown"} | sourceCandidate=${candidate.id}`,
|
||||
},
|
||||
};
|
||||
return `${objectLiteral(entry, 2)},\n`;
|
||||
}
|
||||
|
||||
function defaultInputPath() {
|
||||
return path.join(process.cwd(), "outputs", "review-eval-candidates", "review-eval-candidates.json");
|
||||
}
|
||||
|
||||
function main() {
|
||||
const inputPath = argValue("input", defaultInputPath());
|
||||
const candidateId = argValue("candidate");
|
||||
if (!candidateId) throw new Error("Missing candidate id. Pass --candidate=<id>.");
|
||||
const outputPath = path.resolve(argValue("out", path.join(process.cwd(), "outputs", "review-eval-candidates", `${stableId(candidateId)}.confirmed.ts`)));
|
||||
const candidate = loadCandidate(inputPath, candidateId);
|
||||
const expect = parseExpectedFields();
|
||||
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
||||
const snippet = snippetFor(candidate, expect);
|
||||
fs.writeFileSync(outputPath, snippet, "utf8");
|
||||
console.log(JSON.stringify({ ok: true, candidateId, outputPath, labeledFields: Object.keys(expect) }, null, 2));
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
loadCandidate,
|
||||
parseExpectedFields,
|
||||
snippetFor,
|
||||
stableId,
|
||||
};
|
||||
@@ -0,0 +1,189 @@
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
function argValue(name, fallback = "") {
|
||||
const prefix = `--${name}=`;
|
||||
const match = process.argv.find((entry) => entry.startsWith(prefix));
|
||||
return match ? match.slice(prefix.length) : fallback;
|
||||
}
|
||||
|
||||
function hasFlag(name) {
|
||||
return process.argv.includes(`--${name}`);
|
||||
}
|
||||
|
||||
function defaultAssessmentRoot() {
|
||||
return path.join(process.cwd(), "outputs", "live-soak");
|
||||
}
|
||||
|
||||
function findLatestAssessment(rootDir = defaultAssessmentRoot()) {
|
||||
const resolvedRoot = path.resolve(rootDir);
|
||||
if (!fs.existsSync(resolvedRoot)) throw new Error(`Assessment root not found: ${resolvedRoot}`);
|
||||
const candidates = fs.readdirSync(resolvedRoot, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => {
|
||||
const assessmentPath = path.join(resolvedRoot, entry.name, "scan-performance-assessment.json");
|
||||
if (!fs.existsSync(assessmentPath)) return null;
|
||||
const stat = fs.statSync(assessmentPath);
|
||||
return { assessmentPath, runName: entry.name, mtimeMs: stat.mtimeMs };
|
||||
})
|
||||
.filter(Boolean)
|
||||
.sort((left, right) => {
|
||||
const mtimeDiff = right.mtimeMs - left.mtimeMs;
|
||||
if (Math.abs(mtimeDiff) > 1) return mtimeDiff;
|
||||
return right.runName.localeCompare(left.runName);
|
||||
});
|
||||
if (candidates.length === 0) throw new Error(`No scan-performance-assessment.json found under: ${resolvedRoot}`);
|
||||
return candidates[0].assessmentPath;
|
||||
}
|
||||
|
||||
function loadAssessment(inputPath) {
|
||||
if (!inputPath) throw new Error("Missing assessment file. Pass --input=<scan-performance-assessment.json>.");
|
||||
return JSON.parse(fs.readFileSync(path.resolve(inputPath), "utf8").replace(/^\uFEFF/, ""));
|
||||
}
|
||||
|
||||
function findLimitAssessment(assessment, limit) {
|
||||
if (limit === 100 && assessment?.goal100) return assessment.goal100;
|
||||
const limits = Array.isArray(assessment?.limits) ? assessment.limits : [];
|
||||
return limits.find((entry) => Number(entry?.limit) === limit) || null;
|
||||
}
|
||||
|
||||
function validateAssessment(assessment, options = {}) {
|
||||
const errors = [];
|
||||
const expectedWinner = options.expectedWinner || "any";
|
||||
const expectedLimit = options.limit === undefined ? 100 : Number(options.limit);
|
||||
if (!assessment || typeof assessment !== "object") {
|
||||
return { ok: false, errors: ["Assessment must be a JSON object."] };
|
||||
}
|
||||
|
||||
if (!Number.isInteger(expectedLimit) || expectedLimit < 1) {
|
||||
errors.push(`--limit must be a positive integer, got ${options.limit}.`);
|
||||
}
|
||||
|
||||
const limitAssessment = findLimitAssessment(assessment, expectedLimit);
|
||||
if (!limitAssessment || typeof limitAssessment !== "object") {
|
||||
errors.push(`Missing limit=${expectedLimit} assessment.`);
|
||||
}
|
||||
|
||||
if (expectedLimit === 100 && assessment.goal100Decision !== `qualified-comparison: winner=${limitAssessment?.winnerEngine}`) {
|
||||
errors.push(`goal100Decision is not a qualified comparison: ${assessment.goal100Decision || "<missing>"}`);
|
||||
}
|
||||
|
||||
if (limitAssessment?.limit !== expectedLimit) {
|
||||
errors.push(`limit assessment must be ${expectedLimit}, got ${limitAssessment?.limit ?? "<missing>"}.`);
|
||||
}
|
||||
if (limitAssessment?.comparisonComplete !== true) errors.push(`limit=${expectedLimit}.comparisonComplete must be true.`);
|
||||
if (limitAssessment?.winnerQualified !== true) errors.push(`limit=${expectedLimit}.winnerQualified must be true.`);
|
||||
if (expectedWinner !== "any" && limitAssessment?.winnerEngine !== expectedWinner) {
|
||||
errors.push(`Expected winner '${expectedWinner}', got '${limitAssessment?.winnerEngine ?? "<missing>"}'.`);
|
||||
}
|
||||
|
||||
const engines = Array.isArray(limitAssessment?.engines) ? limitAssessment.engines : [];
|
||||
const engineNames = new Set(engines.map((entry) => entry?.engine));
|
||||
for (const required of ["current", "ik-traineddata"]) {
|
||||
if (!engineNames.has(required)) errors.push(`limit=${expectedLimit} is missing engine result: ${required}.`);
|
||||
}
|
||||
|
||||
const winner = engines.find((entry) => entry?.engine === limitAssessment?.winnerEngine);
|
||||
if (!winner) {
|
||||
errors.push(`Winner engine is missing from limit=${expectedLimit}.engines: ${limitAssessment?.winnerEngine ?? "<missing>"}.`);
|
||||
} else {
|
||||
const winnerMissRate = Number(winner.missRate);
|
||||
const winnerReviewRate = Number(winner.reviewRate);
|
||||
const summaryWinnerMissRate = Number(limitAssessment?.winnerMissRate);
|
||||
const summaryWinnerReviewRate = Number(limitAssessment?.winnerReviewRate);
|
||||
const winnerActiveAverageMsPerParsed = Number(limitAssessment?.winnerActiveAverageMsPerParsed);
|
||||
const winnerActiveProjectedMsFor100 = Number(limitAssessment?.winnerActiveProjectedMsFor100);
|
||||
if (winner.qualified !== true) errors.push("Winner engine result must be qualified.");
|
||||
if (!Number.isFinite(winnerMissRate)) {
|
||||
errors.push(`Winner missRate must be a finite number, got ${winner.missRate ?? "<missing>"}.`);
|
||||
} else if (winnerMissRate > 0.02) {
|
||||
errors.push(`Winner missRate exceeds 2%: ${winner.missRate}.`);
|
||||
}
|
||||
if (!Number.isFinite(winnerReviewRate)) {
|
||||
errors.push(`Winner reviewRate must be a finite number, got ${winner.reviewRate ?? "<missing>"}.`);
|
||||
} else if (winnerReviewRate > 0.15) {
|
||||
errors.push(`Winner reviewRate exceeds 15%: ${winner.reviewRate}.`);
|
||||
}
|
||||
if (!Number.isFinite(summaryWinnerMissRate)) {
|
||||
errors.push(`Winner summary missRate must be a finite number, got ${limitAssessment?.winnerMissRate ?? "<missing>"}.`);
|
||||
} else if (summaryWinnerMissRate > 0.02) {
|
||||
errors.push(`Winner summary missRate exceeds 2%: ${limitAssessment.winnerMissRate}.`);
|
||||
}
|
||||
if (!Number.isFinite(summaryWinnerReviewRate)) {
|
||||
errors.push(`Winner summary reviewRate must be a finite number, got ${limitAssessment?.winnerReviewRate ?? "<missing>"}.`);
|
||||
} else if (summaryWinnerReviewRate > 0.15) {
|
||||
errors.push(`Winner summary reviewRate exceeds 15%: ${limitAssessment.winnerReviewRate}.`);
|
||||
}
|
||||
if (!Number.isFinite(winnerActiveAverageMsPerParsed) || winnerActiveAverageMsPerParsed <= 0) {
|
||||
errors.push(`Winner active average timing must be a positive finite number, got ${limitAssessment?.winnerActiveAverageMsPerParsed ?? "<missing>"}.`);
|
||||
}
|
||||
if (!Number.isFinite(winnerActiveProjectedMsFor100) || winnerActiveProjectedMsFor100 <= 0) {
|
||||
errors.push(`Winner projected100 timing must be a positive finite number, got ${limitAssessment?.winnerActiveProjectedMsFor100 ?? "<missing>"}.`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: errors.length === 0,
|
||||
errors,
|
||||
createdAt: assessment.createdAt || "",
|
||||
limit: expectedLimit,
|
||||
winnerEngine: limitAssessment?.winnerEngine,
|
||||
winnerActiveAverageMsPerParsed: limitAssessment?.winnerActiveAverageMsPerParsed,
|
||||
winnerActiveProjectedMsFor100: limitAssessment?.winnerActiveProjectedMsFor100,
|
||||
winnerMissRate: limitAssessment?.winnerMissRate,
|
||||
winnerReviewRate: limitAssessment?.winnerReviewRate,
|
||||
};
|
||||
}
|
||||
|
||||
function formatSummary(result) {
|
||||
const status = result.ok ? "PASS" : "FAIL";
|
||||
const lines = [
|
||||
`scan assessment: ${status}`,
|
||||
`input: ${result.inputPath || "unknown"}`,
|
||||
`createdAt: ${result.createdAt || "unknown"}`,
|
||||
`limit: ${result.limit ?? "unknown"}`,
|
||||
`winner: ${result.winnerEngine || "unknown"}`,
|
||||
`activeAvg: ${result.winnerActiveAverageMsPerParsed ?? "unknown"}ms/artifact`,
|
||||
`projected100: ${result.winnerActiveProjectedMsFor100 ?? "unknown"}ms`,
|
||||
`missRate: ${result.winnerMissRate ?? "unknown"}`,
|
||||
`reviewRate: ${result.winnerReviewRate ?? "unknown"}`,
|
||||
];
|
||||
if (!result.ok) {
|
||||
lines.push("errors:");
|
||||
for (const error of result.errors) lines.push(`- ${error}`);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function main() {
|
||||
const inputPath = hasFlag("latest")
|
||||
? findLatestAssessment(argValue("root", defaultAssessmentRoot()))
|
||||
: argValue("input", process.argv[2] || "");
|
||||
const expectedWinner = argValue("expect-winner", "any");
|
||||
if (!["any", "current", "ik-traineddata"].includes(expectedWinner)) {
|
||||
throw new Error("--expect-winner must be one of: any, current, ik-traineddata.");
|
||||
}
|
||||
const limit = Number(argValue("limit", "100"));
|
||||
const assessment = loadAssessment(inputPath);
|
||||
const result = validateAssessment(assessment, { expectedWinner, limit });
|
||||
const payload = { inputPath: path.resolve(inputPath), ...result };
|
||||
console.log(hasFlag("summary") ? formatSummary(payload) : JSON.stringify(payload, null, 2));
|
||||
if (!result.ok) process.exit(1);
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
try {
|
||||
main();
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
findLatestAssessment,
|
||||
findLimitAssessment,
|
||||
formatSummary,
|
||||
loadAssessment,
|
||||
validateAssessment,
|
||||
};
|
||||
Reference in New Issue
Block a user