feat(ocr): substat-roll validation + rarity inference for GOOD export
Adds the accuracy check yas / Genshin Optimizer use: a substat value is only legitimate if it equals round(sum of 1..6 rolls) from that stat's roll table. Values that fit no combination at either rarity are guaranteed OCR misreads. - src/lib/substatRolls.ts: 5-star roll tables (+ 4-star %/crit tables to tell rarities apart), pure isPlausibleSubstat/implausibleSubstats, and inferRarity (level > 16 or roll-table fit; conservative, defaults to 5). Validates against the union of rarities so valid 4-star pieces are not false-flagged. - Wired in: shouldFlagArtifactForReview routes implausible substats to review; the parser adds an explanatory note; goodInterop export replaces the hardcoded rarity:5 with inferRarity (fixes wrong 4-star exports to GO/IK). - 10 new unit tests; 131 total green; eval still 100%. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -19,6 +19,7 @@ import {
|
|||||||
textReplacements,
|
textReplacements,
|
||||||
} from "./genshinData.js";
|
} from "./genshinData.js";
|
||||||
import { fuzzyFindKnown, simplifyForMatch } from "./fuzzyMatch.js";
|
import { fuzzyFindKnown, simplifyForMatch } from "./fuzzyMatch.js";
|
||||||
|
import { implausibleSubstats } from "./substatRolls.js";
|
||||||
|
|
||||||
type MainStatValueReference = { stat: string; base: number; max: number };
|
type MainStatValueReference = { stat: string; base: number; max: number };
|
||||||
|
|
||||||
@@ -110,6 +111,8 @@ export function parseArtifactCandidate(capture: CaptureResult | null): ParsedArt
|
|||||||
if (!mainStatField.value) notes.push("Main stat not confidently parsed.");
|
if (!mainStatField.value) notes.push("Main stat not confidently parsed.");
|
||||||
if (!mainValueField.value) notes.push("Main stat value not confidently parsed.");
|
if (!mainValueField.value) notes.push("Main stat value not confidently parsed.");
|
||||||
if (substats.length < 3) notes.push("Substats look incomplete; crop or OCR needs tuning.");
|
if (substats.length < 3) notes.push("Substats look incomplete; crop or OCR needs tuning.");
|
||||||
|
const implausible = implausibleSubstats(substats);
|
||||||
|
if (implausible.length) notes.push(`Substat value has no valid roll combination (likely OCR misread): ${implausible.join(", ")}.`);
|
||||||
if (!setField.value) notes.push("Set name not confidently parsed.");
|
if (!setField.value) notes.push("Set name not confidently parsed.");
|
||||||
|
|
||||||
for (const [label, parsedField] of Object.entries({
|
for (const [label, parsedField] of Object.entries({
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { GoodExportArtifact } from "../types/global";
|
|||||||
import type { StoredArtifactRecord } from "../types/storage";
|
import type { StoredArtifactRecord } from "../types/storage";
|
||||||
import { knownSets, mainStatValueReferences, pieceToSet, pieceToSlot } from "./genshinData";
|
import { knownSets, mainStatValueReferences, pieceToSet, pieceToSlot } from "./genshinData";
|
||||||
import { simplifyForMatch } from "./fuzzyMatch";
|
import { simplifyForMatch } from "./fuzzyMatch";
|
||||||
|
import { inferRarity } from "./substatRolls";
|
||||||
|
|
||||||
// GOOD (Genshin Open Object Description) interop for scanned artifacts, so the
|
// GOOD (Genshin Open Object Description) interop for scanned artifacts, so the
|
||||||
// local store can round-trip with Genshin Optimizer / Inventory Kamera / Akasha
|
// local store can round-trip with Genshin Optimizer / Inventory Kamera / Akasha
|
||||||
@@ -127,7 +128,7 @@ export function storedArtifactToGood(record: StoredArtifactRecord): GoodExportAr
|
|||||||
return {
|
return {
|
||||||
setKey: setNameToKey(record.setName),
|
setKey: setNameToKey(record.setName),
|
||||||
slotKey: SLOT_TO_GOOD[record.slot] ?? "",
|
slotKey: SLOT_TO_GOOD[record.slot] ?? "",
|
||||||
rarity: 5,
|
rarity: inferRarity(record.level ?? 0, record.substats),
|
||||||
level: record.level ?? 0,
|
level: record.level ?? 0,
|
||||||
mainStatKey: statDisplayToGoodKey(record.mainStat),
|
mainStatKey: statDisplayToGoodKey(record.mainStat),
|
||||||
substats,
|
substats,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { ParsedArtifactCandidate } from "./artifactOcrParser";
|
import type { ParsedArtifactCandidate } from "./artifactOcrParser";
|
||||||
import { simplifyForMatch } from "./fuzzyMatch";
|
import { simplifyForMatch } from "./fuzzyMatch";
|
||||||
|
import { implausibleSubstats } from "./substatRolls";
|
||||||
import type { CaptureResult, ReviewSampleRecord } from "../types/global";
|
import type { CaptureResult, ReviewSampleRecord } from "../types/global";
|
||||||
import type { ScannerLearningRulePayload } from "../types/global";
|
import type { ScannerLearningRulePayload } from "../types/global";
|
||||||
|
|
||||||
@@ -73,6 +74,10 @@ export function shouldFlagArtifactForReview(
|
|||||||
if (substatCount === 0) return true;
|
if (substatCount === 0) return true;
|
||||||
if (substatCount < 3 && substatConfidence < 70) return true;
|
if (substatCount < 3 && substatConfidence < 70) return true;
|
||||||
|
|
||||||
|
// A substat value that matches no legal roll combination is a guaranteed OCR
|
||||||
|
// misread - never store it as fact, always review.
|
||||||
|
if (implausibleSubstats(parsed.substats ?? []).length > 0) return true;
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
implausibleSubstats,
|
||||||
|
inferRarity,
|
||||||
|
isPlausibleSubstat,
|
||||||
|
isPlausibleSubstatValue,
|
||||||
|
parseSubstatEntry,
|
||||||
|
} from "./substatRolls";
|
||||||
|
|
||||||
|
describe("substatRolls parsing", () => {
|
||||||
|
it("parses percent and flat entries", () => {
|
||||||
|
expect(parseSubstatEntry("CRIT DMG+13.2%")).toEqual({ stat: "CRIT DMG", value: 13.2, percent: true });
|
||||||
|
expect(parseSubstatEntry("HP+1,509")).toEqual({ stat: "HP", value: 1509, percent: false });
|
||||||
|
expect(parseSubstatEntry("garbage")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("substatRolls plausibility", () => {
|
||||||
|
it("accepts real single-roll values (5-star)", () => {
|
||||||
|
expect(isPlausibleSubstatValue("CRIT DMG", 7.8)).toBe(true); // 7.77 high roll
|
||||||
|
expect(isPlausibleSubstatValue("CRIT DMG", 5.4)).toBe(true); // 5.44 low roll
|
||||||
|
expect(isPlausibleSubstatValue("CRIT Rate", 3.9)).toBe(true); // 3.89
|
||||||
|
expect(isPlausibleSubstatValue("ATK", 19)).toBe(true); // 19.45
|
||||||
|
expect(isPlausibleSubstatValue("HP", 269)).toBe(true); // 268.88
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts multi-roll sums", () => {
|
||||||
|
expect(isPlausibleSubstat("CRIT DMG+13.2%")).toBe(true); // 5.44+7.77 or 6.22+6.99
|
||||||
|
expect(isPlausibleSubstat("Elemental Mastery+68")).toBe(true);
|
||||||
|
expect(isPlausibleSubstat("Energy Recharge+11.7%")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects values that fit no roll combination at either rarity (OCR misreads)", () => {
|
||||||
|
// 8.1% CRIT DMG sits in the gap: single roll maxes at 7.8 (5-star), and the
|
||||||
|
// smallest two-roll sum is 8.2 (4-star), so it is unreachable at both.
|
||||||
|
expect(isPlausibleSubstatValue("CRIT DMG", 8.1)).toBe(false);
|
||||||
|
// 3.5% CRIT DMG is below the lowest possible roll at either rarity.
|
||||||
|
expect(isPlausibleSubstatValue("CRIT DMG", 3.5)).toBe(false);
|
||||||
|
// A dropped digit on flat ATK.
|
||||||
|
expect(isPlausibleSubstatValue("ATK", 5)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not flag unknown stats", () => {
|
||||||
|
expect(isPlausibleSubstatValue("Mystery Stat", 12.3)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("collects the implausible entries from a substat list", () => {
|
||||||
|
const bad = implausibleSubstats(["CRIT DMG+13.2%", "CRIT DMG+8.1%", "ATK+19"]);
|
||||||
|
expect(bad).toEqual(["CRIT DMG+8.1%"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("substatRolls rarity inference", () => {
|
||||||
|
it("is 5-star for any artifact leveled past +16", () => {
|
||||||
|
expect(inferRarity(20, ["ATK%+3.5%"])).toBe(5);
|
||||||
|
expect(inferRarity(17, [])).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects 5-star from a substat that only fits the 5-star table", () => {
|
||||||
|
// 7.8% CRIT DMG is a 5-star single high roll; not reachable at 4-star.
|
||||||
|
expect(inferRarity(12, ["CRIT DMG+7.8%"])).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects 4-star from a substat that only fits the 4-star table", () => {
|
||||||
|
// 4.1% CRIT DMG is a 4-star single low roll; below any 5-star roll.
|
||||||
|
expect(inferRarity(12, ["CRIT DMG+4.1%"])).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults to 5-star when ambiguous", () => {
|
||||||
|
expect(inferRarity(8, [])).toBe(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
// Substat roll validation (the accuracy trick yas / Genshin Optimizer use).
|
||||||
|
// Every artifact substat value is the SUM of discrete per-roll increments: a
|
||||||
|
// substat rolls once when it appears and again at every +4 level, up to 6 rolls
|
||||||
|
// total. So a displayed value is only legitimate if it equals round(sum of N
|
||||||
|
// rolls) for some N in 1..6 from that stat's roll table. An OCR value that fits
|
||||||
|
// no combination is a misread (e.g. a dropped/extra digit) and should go to
|
||||||
|
// review instead of being stored as fact.
|
||||||
|
//
|
||||||
|
// Roll tables are keyed by the app's display stat names. 5-star values are the
|
||||||
|
// full set; 4-star values are included for the stats used to tell rarities
|
||||||
|
// apart. Flat 4-star tables are intentionally omitted (kept conservative).
|
||||||
|
|
||||||
|
const MAX_ROLLS = 6;
|
||||||
|
|
||||||
|
const ROLLS_5STAR: Record<string, number[]> = {
|
||||||
|
HP: [209.13, 239.0, 268.88, 298.75],
|
||||||
|
ATK: [13.62, 15.56, 17.51, 19.45],
|
||||||
|
DEF: [16.2, 18.52, 20.83, 23.15],
|
||||||
|
"HP%": [4.08, 4.66, 5.25, 5.83],
|
||||||
|
"ATK%": [4.08, 4.66, 5.25, 5.83],
|
||||||
|
"DEF%": [5.1, 5.83, 6.56, 7.29],
|
||||||
|
"Elemental Mastery": [16.32, 18.65, 20.98, 23.31],
|
||||||
|
"Energy Recharge": [4.53, 5.18, 5.83, 6.48],
|
||||||
|
"CRIT Rate": [2.72, 3.11, 3.5, 3.89],
|
||||||
|
"CRIT DMG": [5.44, 6.22, 6.99, 7.77],
|
||||||
|
};
|
||||||
|
|
||||||
|
const ROLLS_4STAR: Record<string, number[]> = {
|
||||||
|
"HP%": [3.06, 3.5, 3.93, 4.37],
|
||||||
|
"ATK%": [3.06, 3.5, 3.93, 4.37],
|
||||||
|
"DEF%": [3.83, 4.37, 4.92, 5.47],
|
||||||
|
"Elemental Mastery": [12.25, 13.99, 15.74, 17.48],
|
||||||
|
"Energy Recharge": [3.4, 3.89, 4.37, 4.86],
|
||||||
|
"CRIT Rate": [2.04, 2.33, 2.62, 2.91],
|
||||||
|
"CRIT DMG": [4.08, 4.66, 5.25, 5.83],
|
||||||
|
};
|
||||||
|
|
||||||
|
const PERCENT_STATS = new Set([
|
||||||
|
"HP%",
|
||||||
|
"ATK%",
|
||||||
|
"DEF%",
|
||||||
|
"Energy Recharge",
|
||||||
|
"CRIT Rate",
|
||||||
|
"CRIT DMG",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function isPercent(stat: string) {
|
||||||
|
return PERCENT_STATS.has(stat);
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayRound(value: number, percent: boolean) {
|
||||||
|
return percent ? Math.round(value * 10) / 10 : Math.round(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// All displayed values reachable by summing 1..MAX_ROLLS rolls from `rolls`.
|
||||||
|
function buildValidSet(rolls: number[], percent: boolean): Set<number> {
|
||||||
|
const results = new Set<number>();
|
||||||
|
let sums = new Set<number>([0]);
|
||||||
|
for (let n = 1; n <= MAX_ROLLS; n++) {
|
||||||
|
const next = new Set<number>();
|
||||||
|
for (const sum of sums) {
|
||||||
|
for (const roll of rolls) next.add(Math.round((sum + roll) * 1000) / 1000);
|
||||||
|
}
|
||||||
|
sums = next;
|
||||||
|
for (const sum of sums) results.add(displayRound(sum, percent));
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
const VALID_CACHE = new Map<string, Set<number>>();
|
||||||
|
|
||||||
|
function validSet(stat: string, table: Record<string, number[]>, tag: string): Set<number> | null {
|
||||||
|
const rolls = table[stat];
|
||||||
|
if (!rolls) return null;
|
||||||
|
const key = `${tag}:${stat}`;
|
||||||
|
let cached = VALID_CACHE.get(key);
|
||||||
|
if (!cached) {
|
||||||
|
cached = buildValidSet(rolls, isPercent(stat));
|
||||||
|
VALID_CACHE.set(key, cached);
|
||||||
|
}
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseSubstatEntry(entry: string): { stat: string; value: number; percent: boolean } | null {
|
||||||
|
const plusIndex = entry.indexOf("+");
|
||||||
|
if (plusIndex <= 0) return null;
|
||||||
|
const stat = entry.slice(0, plusIndex).trim();
|
||||||
|
const raw = entry.slice(plusIndex + 1).replace(/,/g, "").replace("%", "").trim();
|
||||||
|
const value = Number.parseFloat(raw);
|
||||||
|
if (!Number.isFinite(value)) return null;
|
||||||
|
return { stat, value, percent: entry.includes("%") };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A displayed value is plausible if it matches a roll sum for 5-star or 4-star. */
|
||||||
|
export function isPlausibleSubstatValue(stat: string, value: number): boolean {
|
||||||
|
const rounded = displayRound(value, isPercent(stat));
|
||||||
|
const five = validSet(stat, ROLLS_5STAR, "5");
|
||||||
|
const four = validSet(stat, ROLLS_4STAR, "4");
|
||||||
|
// Unknown stat (no table) -> do not claim it is implausible.
|
||||||
|
if (!five && !four) return true;
|
||||||
|
return Boolean(five?.has(rounded)) || Boolean(four?.has(rounded));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPlausibleSubstat(entry: string): boolean {
|
||||||
|
const parsed = parseSubstatEntry(entry);
|
||||||
|
if (!parsed) return true;
|
||||||
|
return isPlausibleSubstatValue(parsed.stat, parsed.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The substat entries whose value fits no legal roll combination. */
|
||||||
|
export function implausibleSubstats(substats: readonly string[]): string[] {
|
||||||
|
return substats.filter((entry) => !isPlausibleSubstat(entry));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best-effort rarity from level + which roll table the substats fit. Conservative:
|
||||||
|
* only returns 4 when a substat clearly fits the 4-star table and not 5-star;
|
||||||
|
* otherwise defaults to 5 (the common case and the previous hardcoded value).
|
||||||
|
*/
|
||||||
|
export function inferRarity(level: number, substats: readonly string[]): number {
|
||||||
|
if (level > 16) return 5; // only 5-star artifacts level past +16
|
||||||
|
let fitsFiveOnly = 0;
|
||||||
|
let fitsFourOnly = 0;
|
||||||
|
for (const entry of substats) {
|
||||||
|
const parsed = parseSubstatEntry(entry);
|
||||||
|
if (!parsed) continue;
|
||||||
|
const rounded = displayRound(parsed.value, isPercent(parsed.stat));
|
||||||
|
const five = validSet(parsed.stat, ROLLS_5STAR, "5");
|
||||||
|
const four = validSet(parsed.stat, ROLLS_4STAR, "4");
|
||||||
|
if (!five || !four) continue;
|
||||||
|
const inFive = five.has(rounded);
|
||||||
|
const inFour = four.has(rounded);
|
||||||
|
if (inFive && !inFour) fitsFiveOnly++;
|
||||||
|
else if (inFour && !inFive) fitsFourOnly++;
|
||||||
|
}
|
||||||
|
if (fitsFiveOnly > 0) return 5;
|
||||||
|
if (fitsFourOnly > 0) return 4;
|
||||||
|
return 5;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user