From 98a7a529378bcaed67e24721ba70e3cc071219f2 Mon Sep 17 00:00:00 2001 From: Shen Wang Date: Fri, 27 Feb 2026 07:20:24 -0500 Subject: [PATCH 1/3] feat: add YAML schema adapter and fix config auto-population --- client/src/components/YamlFileUploader.js | 49 ++++- client/src/contexts/GlobalContext.js | 22 +- client/src/utils/yamlSchemaAdapter.js | 236 ++++++++++++++++++++++ client/src/views/ModelInference.js | 4 +- client/src/views/ModelTraining.js | 4 +- server_pytc/services/model.py | 2 +- 6 files changed, 300 insertions(+), 17 deletions(-) create mode 100644 client/src/utils/yamlSchemaAdapter.js diff --git a/client/src/components/YamlFileUploader.js b/client/src/components/YamlFileUploader.js index 03d958fb..97b56cd4 100644 --- a/client/src/components/YamlFileUploader.js +++ b/client/src/components/YamlFileUploader.js @@ -27,6 +27,7 @@ import { getModelArchitectures, } from "../api"; import { findCommonPartOfString } from "../utils"; +import { adaptToPytcSchema } from "../utils/yamlSchemaAdapter"; const YamlFileUploader = (props) => { const context = useContext(AppContext); @@ -132,22 +133,39 @@ const YamlFileUploader = (props) => { }; const updateInputSelectorInformation = (yamlData) => { - const inputImagePath = getPathValue(context.inputImage); - const inputLabelPath = getPathValue(context.inputLabel); + const ds = yamlData.DATASET || {}; + + // Phase 1: Auto-populate Step 0 path slots from YAML if UI fields are empty. + if (!getPathValue(context.inputImage) && ds.INPUT_PATH && ds.IMAGE_NAME) { + context.setInputImage(ds.INPUT_PATH + ds.IMAGE_NAME); + } + if (!getPathValue(context.inputLabel) && ds.INPUT_PATH && ds.LABEL_NAME) { + context.setInputLabel(ds.INPUT_PATH + ds.LABEL_NAME); + } + if (!getPathValue(context.outputPath) && ds.OUTPUT_PATH) { + context.setOutputPath(ds.OUTPUT_PATH); + } + + // Phase 2: Write current UI values back into the YAML (only if set). + const inputImagePath = + getPathValue(context.inputImage) || + (ds.INPUT_PATH && ds.IMAGE_NAME ? ds.INPUT_PATH + ds.IMAGE_NAME : ""); + const inputLabelPath = + getPathValue(context.inputLabel) || + (ds.INPUT_PATH && ds.LABEL_NAME ? ds.INPUT_PATH + ds.LABEL_NAME : ""); if (!inputImagePath || !inputLabelPath) { + yamlData.DATASET = ds; return; } - // INPUT_PATH is the shared parent directory for image/label so names can be relative. const inputPath = findCommonPartOfString(inputImagePath, inputLabelPath); - - yamlData.DATASET = yamlData.DATASET || {}; - yamlData.DATASET.INPUT_PATH = inputPath || yamlData.DATASET.INPUT_PATH; + yamlData.DATASET = ds; + yamlData.DATASET.INPUT_PATH = inputPath || ds.INPUT_PATH; yamlData.DATASET.IMAGE_NAME = inputImagePath.replace(inputPath, ""); yamlData.DATASET.LABEL_NAME = inputLabelPath.replace(inputPath, ""); - const outputPath = getPathValue(context.outputPath); + const outputPath = getPathValue(context.outputPath) || ds.OUTPUT_PATH || ""; if (outputPath) { yamlData.DATASET.OUTPUT_PATH = outputPath; } @@ -183,12 +201,25 @@ const YamlFileUploader = (props) => { } }; - const applyYamlData = (yamlData, sourceLabel) => { - if (!yamlData) { + const applyYamlData = (rawYamlData, sourceLabel) => { + if (!rawYamlData) { message.error("Failed to load YAML configuration."); return; } + // Detect schema and adapt to pytorch_connectomics format if needed. + const { + adapted: yamlData, + originalSchema, + wasAdapted, + } = adaptToPytcSchema(rawYamlData); + if (wasAdapted) { + message.info( + `Detected '${originalSchema}' schema — automatically converted to pytorch_connectomics format.`, + 5, + ); + } + updateInputSelectorInformation(yamlData); const serialized = yaml .dump(yamlData, { indent: 2 }) diff --git a/client/src/contexts/GlobalContext.js b/client/src/contexts/GlobalContext.js index 63169f06..96ea231a 100644 --- a/client/src/contexts/GlobalContext.js +++ b/client/src/contexts/GlobalContext.js @@ -73,12 +73,32 @@ function usePersistedState(key, defaultValue) { const [isLoaded, setIsLoaded] = useState(false); useEffect(() => { - // Fetch the stored value asynchronously when the component mounts + // Fetch the stored value asynchronously when the component mounts. + // Migration guard: if localforage has no value but localStorage does, + // migrate the legacy value over and clear the stale localStorage entry. localforage .getItem(key) .then((storedValue) => { if (storedValue !== null) { setState(storedValue); + } else { + const legacy = localStorage.getItem(key); + if (legacy !== null) { + try { + // localforage can store raw values, but localStorage only stores + // strings, so attempt a JSON parse first. + const parsed = JSON.parse(legacy); + setState(parsed); + localforage.setItem(key, parsed).catch(() => {}); + } catch { + setState(legacy); + localforage.setItem(key, legacy).catch(() => {}); + } + localStorage.removeItem(key); + console.info( + `[GlobalContext] Migrated '${key}' from localStorage → localforage.`, + ); + } } setIsLoaded(true); }) diff --git a/client/src/utils/yamlSchemaAdapter.js b/client/src/utils/yamlSchemaAdapter.js new file mode 100644 index 00000000..db1fda82 --- /dev/null +++ b/client/src/utils/yamlSchemaAdapter.js @@ -0,0 +1,236 @@ +/** + * yamlSchemaAdapter.js + * + * Detects the schema family of a parsed YAML object and translates it to the + * flat uppercase schema required by pytorch_connectomics / yacs. + * + * Supported schemas: + * - "pytc" : pytorch_connectomics native (SYSTEM, MODEL, DATASET, SOLVER, INFERENCE) + * - "lucchi+" : Lucchi++ / MONAI-style (system, model, data, optimization, inference) + */ + +// ─── Schema Detection ───────────────────────────────────────────────────────── + +/** + * Returns the schema family name for the given parsed YAML object, + * or "unknown" if it cannot be identified. + * @param {object} yamlData + * @returns {"pytc"|"lucchi+"|"unknown"} + */ +export function detectSchema(yamlData) { + if (!yamlData || typeof yamlData !== "object") return "unknown"; + + const pytcKeys = ["SYSTEM", "MODEL", "DATASET", "SOLVER", "INFERENCE"]; + if (pytcKeys.some((k) => k in yamlData)) return "pytc"; + + const lucchiKeys = ["data", "optimization", "experiment_name"]; + if (lucchiKeys.some((k) => k in yamlData)) return "lucchi+"; + + return "unknown"; +} + +// ─── Path Helpers ───────────────────────────────────────────────────────────── + +/** + * Splits a file path into [directory, filename], handling both / and \ so the + * adapter works correctly on Windows as well as POSIX systems. + * + * Examples: + * "datasets/lucchi++/train_im.h5" → ["datasets/lucchi++/", "train_im.h5"] + * "C:\\data\\train.tif" → ["C:\\data\\", "train.tif"] + * "train_im.h5" → ["", "train_im.h5"] + */ +function _splitPath(fullPath) { + if (!fullPath || typeof fullPath !== "string") return ["", ""]; + const match = fullPath.match(/.*[/\\]/); + const dir = match ? match[0] : ""; + const fileName = fullPath.slice(dir.length); + return [dir, fileName]; +} + +/** + * Maps Lucchi++ architecture names to pytorch_connectomics MODEL_MAP keys. + */ +function _mapArchitecture(arch) { + const map = { + monai_unet: "unet_3d", + monai_basic_unet3d: "unet_3d", + rsunet: "unet_3d", + mednext: "unet_3d", + unet: "unet_3d", + unet3d: "unet_3d", + unet2d: "unet_2d", + fpn: "fpn_3d", + }; + return map[(arch || "").toLowerCase()] ?? "unet_3d"; +} + +/** + * Maps Lucchi++ scheduler names to pytorch_connectomics scheduler names. + */ +function _mapScheduler(name) { + const map = { + ReduceLROnPlateau: "MultiStepLR", + reduceLROnPlateau: "MultiStepLR", + CosineAnnealingLR: "CosineAnnealingLR", + cosineannealinglr: "CosineAnnealingLR", + warmupcosine: "WarmupCosineLR", + WarmupCosineLR: "WarmupCosineLR", + }; + return map[name] ?? "MultiStepLR"; +} + +/** + * Computes a STRIDE array from a window size and overlap fraction. + * e.g. window [112,112,112] with overlap 0.25 → stride [84,84,84] + */ +function _computeStride(windowSize, overlap) { + const ws = windowSize ?? [112, 112, 112]; + const ov = overlap ?? 0.5; + return ws.map((s) => Math.round(s * (1 - ov))); +} + +// ─── Adapter: Lucchi++ / MONAI → pytorch_connectomics ──────────────────────── + +/** + * Translates a Lucchi++ schema YAML object to the pytorch_connectomics schema. + * Keys that have no equivalent are preserved in a top-level `_EXTRA` object + * so that no data is silently discarded during translation. + * + * @param {object} src - Parsed Lucchi++ YAML object. + * @returns {object} - Translated pytorch_connectomics schema object. + */ +function adaptLucchiPlus(src) { + const out = {}; + + // ── SYSTEM ────────────────────────────────────────────────────────────── + const sys = src.system || {}; + const sysTrain = sys.training || {}; + const sysInfer = sys.inference || {}; + out.SYSTEM = { + NUM_GPUS: sysTrain.num_gpus ?? sysInfer.num_gpus ?? 1, + NUM_CPUS: sysTrain.num_cpus ?? sysInfer.num_cpus ?? 4, + DISTRIBUTED: false, + PARALLEL: "DP", + }; + + // ── MODEL ──────────────────────────────────────────────────────────────── + const mod = src.model || {}; + out.MODEL = { + ARCHITECTURE: _mapArchitecture(mod.architecture), + IN_PLANES: mod.in_channels ?? 1, + OUT_PLANES: mod.out_channels ?? 1, + INPUT_SIZE: mod.input_size ?? [112, 112, 112], + OUTPUT_SIZE: mod.output_size ?? mod.input_size ?? [112, 112, 112], + FILTERS: mod.filters ?? [32, 64, 128, 256], + }; + if (mod.loss_functions) { + out.MODEL.LOSS_OPTION = [mod.loss_functions]; + out.MODEL.LOSS_WEIGHT = [ + mod.loss_weights ?? mod.loss_functions.map(() => 1.0), + ]; + out.MODEL.OUTPUT_ACT = [mod.loss_functions.map(() => "none")]; + } + + // ── DATASET ────────────────────────────────────────────────────────────── + const data = src.data || {}; + const [trainInputPath, trainImageName] = _splitPath(data.train_image); + const [, trainLabelName] = _splitPath(data.train_label); + out.DATASET = { + INPUT_PATH: trainInputPath || "path/to/input", + IMAGE_NAME: trainImageName || "", + LABEL_NAME: trainLabelName || "", + OUTPUT_PATH: "path/to/output", + IS_ISOTROPIC: true, + PAD_SIZE: data.pad_size ?? [0, 0, 0], + }; + + // ── SOLVER ─────────────────────────────────────────────────────────────── + const opt = src.optimization || {}; + const optOpt = opt.optimizer || {}; + const sched = opt.scheduler || {}; + out.SOLVER = { + NAME: optOpt.name ?? "Adam", + BASE_LR: optOpt.lr ?? 0.001, + WEIGHT_DECAY: optOpt.weight_decay ?? 0.0001, + MOMENTUM: 0.9, + BETAS: optOpt.betas ?? [0.9, 0.999], + ITERATION_TOTAL: + (opt.max_epochs ?? 1000) * (data.iter_num_per_epoch ?? 1000), + SAMPLES_PER_BATCH: sysTrain.batch_size ?? 2, + LR_SCHEDULER_NAME: _mapScheduler(sched.name), + ITERATION_SAVE: 5000, + ITERATION_VAL: 5000, + }; + + // ── INFERENCE ──────────────────────────────────────────────────────────── + const inf = src.inference || {}; + const infData = inf.data || {}; + const sw = inf.sliding_window || {}; + const [infInputPath, infImageName] = _splitPath(infData.test_image); + out.INFERENCE = { + INPUT_PATH: infInputPath || out.DATASET.INPUT_PATH, + IMAGE_NAME: infImageName || "", + INPUT_SIZE: sw.window_size ?? out.MODEL.INPUT_SIZE, + OUTPUT_SIZE: sw.window_size ?? out.MODEL.OUTPUT_SIZE, + OUTPUT_PATH: "", + OUTPUT_NAME: "result.h5", + SAMPLES_PER_BATCH: sysInfer.batch_size ?? 4, + AUG_MODE: "mean", + AUG_NUM: null, + BLENDING: sw.blending ?? "gaussian", + STRIDE: _computeStride(sw.window_size, sw.overlap), + }; + + // ── _EXTRA: collect all unmapped top-level keys ────────────────────────── + // This ensures no data is lost during translation. Consumers can inspect + // _EXTRA to find schema fields that have no pytc equivalent. + const mappedKeys = new Set([ + "system", + "model", + "data", + "optimization", + "inference", + ]); + const extra = {}; + for (const [key, value] of Object.entries(src)) { + if (!mappedKeys.has(key)) { + extra[key] = value; + } + } + if (Object.keys(extra).length > 0) { + out._EXTRA = extra; + } + + return out; +} + +// ─── Public API ─────────────────────────────────────────────────────────────── + +/** + * Takes a parsed YAML object (any supported schema) and returns a parsed YAML + * object conforming to the pytorch_connectomics flat uppercase schema. + * + * - If the schema is already "pytc", returns the object unchanged. + * - If the schema is unknown, returns the object unchanged with a console warn. + * + * @param {object} yamlData + * @returns {{ adapted: object, originalSchema: string, wasAdapted: boolean }} + */ +export function adaptToPytcSchema(yamlData) { + const schema = detectSchema(yamlData); + + if (schema === "pytc") { + return { adapted: yamlData, originalSchema: "pytc", wasAdapted: false }; + } + if (schema === "lucchi+") { + const adapted = adaptLucchiPlus(yamlData); + return { adapted, originalSchema: "lucchi+", wasAdapted: true }; + } + + console.warn( + "[yamlSchemaAdapter] Unknown YAML schema — returning as-is. " + + "Ensure top-level keys match pytorch_connectomics conventions.", + ); + return { adapted: yamlData, originalSchema: "unknown", wasAdapted: false }; +} diff --git a/client/src/views/ModelInference.js b/client/src/views/ModelInference.js index d6329239..b4eaf5b6 100644 --- a/client/src/views/ModelInference.js +++ b/client/src/views/ModelInference.js @@ -10,7 +10,7 @@ function ModelInference({ isInferring, setIsInferring }) { const handleStartButton = async () => { try { setIsInferring(true); - const inferenceConfig = localStorage.getItem("inferenceConfig"); + const inferenceConfig = context.inferenceConfig; const getPath = (val) => { if (!val) return ""; @@ -18,9 +18,7 @@ function ModelInference({ isInferring, setIsInferring }) { return val.path || ""; }; - // const res = startModelInference( const res = await startModelInference( - context.uploadedYamlFile.name, inferenceConfig, getPath(context.outputPath), getPath(context.checkpointPath), diff --git a/client/src/views/ModelTraining.js b/client/src/views/ModelTraining.js index 14907c27..2508c403 100644 --- a/client/src/views/ModelTraining.js +++ b/client/src/views/ModelTraining.js @@ -83,9 +83,7 @@ function ModelTraining() { } console.log(context.uploadedYamlFile); - const trainingConfig = - localStorage.getItem("trainingConfig") || context.trainingConfig; - console.log(trainingConfig); + const trainingConfig = context.trainingConfig; setIsTraining(true); setTrainingStatus( diff --git a/server_pytc/services/model.py b/server_pytc/services/model.py index 30bf03d3..d86e8877 100644 --- a/server_pytc/services/model.py +++ b/server_pytc/services/model.py @@ -71,7 +71,7 @@ def start_training(dict: dict): command = [sys.executable, str(script_path)] print(f"[MODEL.PY] Processing command-line arguments...") - for key, value in dict["arguments"].items(): + for key, value in dict.get("arguments", {}).items(): if value is not None: print(f"[MODEL.PY] Adding --{key} {value}") command.extend([f"--{key}", str(value)]) From 4fa1c7708c67bb2ebf7da8cf164c3b02a06ef70b Mon Sep 17 00:00:00 2001 From: Shen Wang Date: Fri, 27 Feb 2026 07:54:34 -0500 Subject: [PATCH 2/3] chore: trigger CI after force push From bd066273920ab610afc2b3d09f43ba92ab7473ee Mon Sep 17 00:00:00 2001 From: Shen Wang Date: Tue, 10 Mar 2026 17:15:19 -0400 Subject: [PATCH 3/3] refactor: implement generalist YAML parser compatible with both YACS and Hydra styles --- client/src/utils/yamlSchemaAdapter.js | 94 +++++++++++++++++++++------ 1 file changed, 73 insertions(+), 21 deletions(-) diff --git a/client/src/utils/yamlSchemaAdapter.js b/client/src/utils/yamlSchemaAdapter.js index db1fda82..228a862c 100644 --- a/client/src/utils/yamlSchemaAdapter.js +++ b/client/src/utils/yamlSchemaAdapter.js @@ -4,27 +4,69 @@ * Detects the schema family of a parsed YAML object and translates it to the * flat uppercase schema required by pytorch_connectomics / yacs. * + * This adapter identifies "standard" configuration schemas as defined in the + * pytorch_connectomics core library. Both uppercase (YACS-style) and lowercase + * (Hydra/general-style) YAML configurations are recognised generically, + * without hardcoding any specific dataset names. + * * Supported schemas: - * - "pytc" : pytorch_connectomics native (SYSTEM, MODEL, DATASET, SOLVER, INFERENCE) - * - "lucchi+" : Lucchi++ / MONAI-style (system, model, data, optimization, inference) + * - "pytc" : pytorch_connectomics client config (contains pytc_version or workflow) + * - "standard" : pytorch_connectomics core library config, uppercase or lowercase form + * Pattern A (uppercase / YACS): ≥2 of MODEL, DATASET, SOLVER, INFERENCE, SYSTEM + * Pattern B (lowercase / Hydra): ≥2 of model, data, optimization, train, monitor + * - "unknown" : does not match any known schema */ // ─── Schema Detection ───────────────────────────────────────────────────────── +/** + * Top-level keys that identify a pytc client-enriched config (highest priority). + * These fields are added by the pytc-client and are not present in plain + * pytorch_connectomics library configs. + */ +const PYTC_KEYS = ["pytc_version", "workflow"]; + +/** + * Pattern A: uppercase (YACS-style) pytorch_connectomics core keys. + * A YAML must contain at least STANDARD_MIN_MATCH of these to be "standard". + */ +const STANDARD_KEYS_UPPER = ["MODEL", "DATASET", "SOLVER", "INFERENCE", "SYSTEM"]; + +/** + * Pattern B: lowercase (Hydra/general-style) pytorch_connectomics core keys. + * A YAML must contain at least STANDARD_MIN_MATCH of these to be "standard". + */ +const STANDARD_KEYS_LOWER = ["model", "data", "optimization", "train", "monitor"]; + +/** Minimum number of matching keys required to classify a config as "standard". */ +const STANDARD_MIN_MATCH = 2; + /** * Returns the schema family name for the given parsed YAML object, * or "unknown" if it cannot be identified. + * + * Detection order (highest → lowest priority): + * 1. "pytc" — contains any key in PYTC_KEYS + * 2. "standard" — Pattern A: ≥2 keys from STANDARD_KEYS_UPPER + * 3. "standard" — Pattern B: ≥2 keys from STANDARD_KEYS_LOWER + * 4. "unknown" + * * @param {object} yamlData - * @returns {"pytc"|"lucchi+"|"unknown"} + * @returns {"pytc"|"standard"|"unknown"} */ export function detectSchema(yamlData) { if (!yamlData || typeof yamlData !== "object") return "unknown"; - const pytcKeys = ["SYSTEM", "MODEL", "DATASET", "SOLVER", "INFERENCE"]; - if (pytcKeys.some((k) => k in yamlData)) return "pytc"; + // 1. Highest priority: pytc client-enriched config + if (PYTC_KEYS.some((k) => k in yamlData)) return "pytc"; - const lucchiKeys = ["data", "optimization", "experiment_name"]; - if (lucchiKeys.some((k) => k in yamlData)) return "lucchi+"; + // 2. Pattern A — uppercase YACS-style core config + const upperMatches = STANDARD_KEYS_UPPER.filter((k) => k in yamlData).length; + if (upperMatches >= STANDARD_MIN_MATCH) return "standard"; + + // 3. Pattern B — lowercase Hydra/general-style core config + const lowerMatches = STANDARD_KEYS_LOWER.filter((k) => k in yamlData).length; + if (lowerMatches >= STANDARD_MIN_MATCH) return "standard"; return "unknown"; } @@ -36,9 +78,9 @@ export function detectSchema(yamlData) { * adapter works correctly on Windows as well as POSIX systems. * * Examples: - * "datasets/lucchi++/train_im.h5" → ["datasets/lucchi++/", "train_im.h5"] - * "C:\\data\\train.tif" → ["C:\\data\\", "train.tif"] - * "train_im.h5" → ["", "train_im.h5"] + * "datasets/train_im.h5" → ["datasets/", "train_im.h5"] + * "C:\\data\\train.tif" → ["C:\\data\\", "train.tif"] + * "train_im.h5" → ["", "train_im.h5"] */ function _splitPath(fullPath) { if (!fullPath || typeof fullPath !== "string") return ["", ""]; @@ -49,7 +91,7 @@ function _splitPath(fullPath) { } /** - * Maps Lucchi++ architecture names to pytorch_connectomics MODEL_MAP keys. + * Maps general lowercase architecture names to pytorch_connectomics MODEL_MAP keys. */ function _mapArchitecture(arch) { const map = { @@ -66,7 +108,7 @@ function _mapArchitecture(arch) { } /** - * Maps Lucchi++ scheduler names to pytorch_connectomics scheduler names. + * Maps general lowercase scheduler names to pytorch_connectomics scheduler names. */ function _mapScheduler(name) { const map = { @@ -90,17 +132,19 @@ function _computeStride(windowSize, overlap) { return ws.map((s) => Math.round(s * (1 - ov))); } -// ─── Adapter: Lucchi++ / MONAI → pytorch_connectomics ──────────────────────── +// ─── Adapter: lowercase / Hydra-style → pytorch_connectomics uppercase ──────── /** - * Translates a Lucchi++ schema YAML object to the pytorch_connectomics schema. + * Translates a lowercase (Hydra/general) standard schema YAML object to the + * pytorch_connectomics uppercase (YACS) schema. + * * Keys that have no equivalent are preserved in a top-level `_EXTRA` object * so that no data is silently discarded during translation. * - * @param {object} src - Parsed Lucchi++ YAML object. + * @param {object} src - Parsed lowercase standard YAML object. * @returns {object} - Translated pytorch_connectomics schema object. */ -function adaptLucchiPlus(src) { +function adaptStandardLower(src) { const out = {}; // ── SYSTEM ────────────────────────────────────────────────────────────── @@ -209,9 +253,10 @@ function adaptLucchiPlus(src) { /** * Takes a parsed YAML object (any supported schema) and returns a parsed YAML - * object conforming to the pytorch_connectomics flat uppercase schema. + * object conforming to the pytorch_connectomics flat uppercase (YACS) schema. * - * - If the schema is already "pytc", returns the object unchanged. + * - If the schema is "pytc" or already uppercase "standard", returns unchanged. + * - If the schema is lowercase "standard", translates to uppercase. * - If the schema is unknown, returns the object unchanged with a console warn. * * @param {object} yamlData @@ -223,9 +268,16 @@ export function adaptToPytcSchema(yamlData) { if (schema === "pytc") { return { adapted: yamlData, originalSchema: "pytc", wasAdapted: false }; } - if (schema === "lucchi+") { - const adapted = adaptLucchiPlus(yamlData); - return { adapted, originalSchema: "lucchi+", wasAdapted: true }; + + if (schema === "standard") { + // Check whether this is already uppercase (Pattern A) — if so, pass through. + const upperMatches = STANDARD_KEYS_UPPER.filter((k) => k in yamlData).length; + if (upperMatches >= STANDARD_MIN_MATCH) { + return { adapted: yamlData, originalSchema: "standard", wasAdapted: false }; + } + // Pattern B: lowercase — translate to uppercase. + const adapted = adaptStandardLower(yamlData); + return { adapted, originalSchema: "standard", wasAdapted: true }; } console.warn(