From 7930e369a7db9b691748ce4a84fb22814036f97c Mon Sep 17 00:00:00 2001 From: AzuTear Date: Mon, 6 Jul 2026 16:21:28 +0200 Subject: [PATCH] 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 --- src/lib/artifactOcrParser.ts | 3 + src/lib/goodInterop.ts | 3 +- src/lib/scannerLearning.ts | 5 ++ src/lib/substatRolls.test.ts | 72 ++++++++++++++++++ src/lib/substatRolls.ts | 139 +++++++++++++++++++++++++++++++++++ 5 files changed, 221 insertions(+), 1 deletion(-) create mode 100644 src/lib/substatRolls.test.ts create mode 100644 src/lib/substatRolls.ts diff --git a/src/lib/artifactOcrParser.ts b/src/lib/artifactOcrParser.ts index 79ff8cf..cd7df7b 100644 --- a/src/lib/artifactOcrParser.ts +++ b/src/lib/artifactOcrParser.ts @@ -19,6 +19,7 @@ import { textReplacements, } from "./genshinData.js"; import { fuzzyFindKnown, simplifyForMatch } from "./fuzzyMatch.js"; +import { implausibleSubstats } from "./substatRolls.js"; 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 (!mainValueField.value) notes.push("Main stat value not confidently parsed."); 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."); for (const [label, parsedField] of Object.entries({ diff --git a/src/lib/goodInterop.ts b/src/lib/goodInterop.ts index 1a4a492..468e285 100644 --- a/src/lib/goodInterop.ts +++ b/src/lib/goodInterop.ts @@ -2,6 +2,7 @@ import type { GoodExportArtifact } from "../types/global"; import type { StoredArtifactRecord } from "../types/storage"; import { knownSets, mainStatValueReferences, pieceToSet, pieceToSlot } from "./genshinData"; import { simplifyForMatch } from "./fuzzyMatch"; +import { inferRarity } from "./substatRolls"; // GOOD (Genshin Open Object Description) interop for scanned artifacts, so the // local store can round-trip with Genshin Optimizer / Inventory Kamera / Akasha @@ -127,7 +128,7 @@ export function storedArtifactToGood(record: StoredArtifactRecord): GoodExportAr return { setKey: setNameToKey(record.setName), slotKey: SLOT_TO_GOOD[record.slot] ?? "", - rarity: 5, + rarity: inferRarity(record.level ?? 0, record.substats), level: record.level ?? 0, mainStatKey: statDisplayToGoodKey(record.mainStat), substats, diff --git a/src/lib/scannerLearning.ts b/src/lib/scannerLearning.ts index 31e15c1..597ad1a 100644 --- a/src/lib/scannerLearning.ts +++ b/src/lib/scannerLearning.ts @@ -1,5 +1,6 @@ import type { ParsedArtifactCandidate } from "./artifactOcrParser"; import { simplifyForMatch } from "./fuzzyMatch"; +import { implausibleSubstats } from "./substatRolls"; import type { CaptureResult, ReviewSampleRecord } from "../types/global"; import type { ScannerLearningRulePayload } from "../types/global"; @@ -73,6 +74,10 @@ export function shouldFlagArtifactForReview( if (substatCount === 0) 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; } diff --git a/src/lib/substatRolls.test.ts b/src/lib/substatRolls.test.ts new file mode 100644 index 0000000..7e14059 --- /dev/null +++ b/src/lib/substatRolls.test.ts @@ -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); + }); +}); diff --git a/src/lib/substatRolls.ts b/src/lib/substatRolls.ts new file mode 100644 index 0000000..3ca8eb7 --- /dev/null +++ b/src/lib/substatRolls.ts @@ -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 = { + 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 = { + "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 { + const results = new Set(); + let sums = new Set([0]); + for (let n = 1; n <= MAX_ROLLS; n++) { + const next = new Set(); + 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>(); + +function validSet(stat: string, table: Record, tag: string): Set | 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; +}