Source: core/formula/dag-js-generator.js

/*
 * Copyright 2026 The Ray Optics Simulation authors and contributors
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

import {
  collectNodeLabels,
  collectReachableNodeIds,
  validateDagShape,
} from "./dag-util.js";

export const JS_RUNTIME_CODE = `
function finiteOrNaN(value) {
  return Number.isFinite(value) ? value : NaN;
}

function guardNonzero(test, value) {
  if (!Number.isFinite(test) || !Number.isFinite(value)) return NaN;
  return test !== 0 ? value : NaN;
}

function guardNonNegative(test, value) {
  if (!Number.isFinite(test) || !Number.isFinite(value)) return NaN;
  return test >= 0 ? value : NaN;
}

function guardNotInteger(test, value) {
  if (!Number.isFinite(test) || !Number.isFinite(value)) return NaN;
  return !Number.isInteger(test) ? value : NaN;
}

function guardValid(test, value) {
  if (!Number.isFinite(test) || !Number.isFinite(value)) return NaN;
  return value;
}

function fallback(value, fallbackValue) {
  return Number.isFinite(value) ? value : fallbackValue;
}
`.trim();

/**
 * Generate JavaScript source for evaluating labeled DAG nodes.
 *
 * The returned source is not evaluated. Callers that trust the DAG may load it
 * with `new Function` or write it to a file.
 *
 * @param {{nodes: Array<object>}} dag - DAG to generate source for.
 * @param {Object} [options={}] - Generator options.
 * @param {string[]} [options.labels] - Optional ordered output labels.
 * @param {string} [options.functionName] - Generated evaluator function name.
 * @returns {Object} Generated source code and metadata.
 */
export function generateDagJsEvaluator(dag, options = {}) {
  validateDagShape(dag);
  const functionName = validateJsIdentifier(options.functionName ?? "evaluateDag", "functionName");
  const labels = selectOutputLabels(dag, options.labels);
  const reachableNodes = collectReachableNodeIds(
    dag,
    labels.map(([_label, id]) => id),
  );

  const body = [
    "  params = params ?? Object.create(null);",
    "  const output = Object.create(null);",
  ];

  for (const node of dag.nodes) {
    if (!reachableNodes.has(node.id)) continue;
    body.push(`  const v${node.id} = finiteOrNaN(${generatedNodeExpression(node)});`);
  }
  for (const [label, id] of labels) {
    body.push(`  output[${JSON.stringify(label)}] = v${id};`);
  }
  body.push("  return output;");

  const functionCode = `function ${functionName}(params = Object.create(null)) {
${body.join("\n")}
}`;
  return {
    code: `${JS_RUNTIME_CODE}\n\n${functionCode}\n`,
    runtimeCode: JS_RUNTIME_CODE,
    functionCode,
    labels: labels.map(([label]) => label),
    functionName,
  };
}

function selectOutputLabels(dag, labels) {
  const labelIds = collectNodeLabels(dag);
  if (labels === undefined) return [...labelIds.entries()];
  if (!Array.isArray(labels)) throw new TypeError("labels must be an array");
  const seen = new Set();
  return labels.map((label) => {
    if (typeof label !== "string" || label.length === 0) {
      throw new TypeError("labels must contain non-empty strings");
    }
    if (seen.has(label)) throw new TypeError(`Duplicate label: ${JSON.stringify(label)}`);
    seen.add(label);
    const id = labelIds.get(label);
    if (id === undefined) throw new TypeError(`Unknown label: ${JSON.stringify(label)}`);
    return [label, id];
  });
}

function generatedNodeExpression(node) {
  if (node.kind === "number") return numberLiteral(node.value);
  if (node.kind === "constant") return numberLiteral(constantValue(node.name));
  if (node.kind === "parameter") return `Number(params[${JSON.stringify(node.name)}])`;
  if (node.kind === "unary") return generatedUnaryExpression(node.op, node.args[0]);
  if (node.kind === "binary") return generatedBinaryExpression(node.op, node.args[0], node.args[1]);
  if (node.kind === "call") return generatedCallExpression(node.name, node.args);
  throw new TypeError(`Unknown DAG node kind: ${JSON.stringify(node.kind)}`);
}

function generatedUnaryExpression(op, arg) {
  if (op === "-") return `(-v${arg})`;
  throw new TypeError(`Unknown unary operator: ${JSON.stringify(op)}`);
}

function generatedBinaryExpression(op, left, right) {
  if (op === "+") return `(v${left} + v${right})`;
  if (op === "-") return `(v${left} - v${right})`;
  if (op === "*") return `(v${left} * v${right})`;
  if (op === "/") return `(v${left} / v${right})`;
  if (op === "^") return `(v${left} ** v${right})`;
  throw new TypeError(`Unknown binary operator: ${JSON.stringify(op)}`);
}

function generatedCallExpression(name, args) {
  const values = args.map((id) => `v${id}`);
  if (name === "sqrt") return `Math.sqrt(${values[0]})`;
  if (name === "sin") return `Math.sin(${values[0]})`;
  if (name === "cos") return `Math.cos(${values[0]})`;
  if (name === "tan") return `Math.tan(${values[0]})`;
  if (name === "sec") return `(1 / Math.cos(${values[0]}))`;
  if (name === "csc") return `(1 / Math.sin(${values[0]}))`;
  if (name === "cot") return `(1 / Math.tan(${values[0]}))`;
  if (name === "sinh") return `Math.sinh(${values[0]})`;
  if (name === "cosh") return `Math.cosh(${values[0]})`;
  if (name === "tanh") return `Math.tanh(${values[0]})`;
  if (name === "log") return `Math.log(${values[0]})`;
  if (name === "exp") return `Math.exp(${values[0]})`;
  if (name === "asin") return `Math.asin(${values[0]})`;
  if (name === "acos") return `Math.acos(${values[0]})`;
  if (name === "atan") return `Math.atan(${values[0]})`;
  if (name === "atan2") return `Math.atan2(${values[0]}, ${values[1]})`;
  if (name === "asinh") return `Math.asinh(${values[0]})`;
  if (name === "acosh") return `Math.acosh(${values[0]})`;
  if (name === "atanh") return `Math.atanh(${values[0]})`;
  if (name === "floor") return `Math.floor(${values[0]})`;
  if (name === "round") return `Math.round(${values[0]})`;
  if (name === "ceil") return `Math.ceil(${values[0]})`;
  if (name === "fix") return `Math.trunc(${values[0]})`;
  if (name === "abs") return `Math.abs(${values[0]})`;
  if (name === "sign") return `Math.sign(${values[0]})`;
  if (name === "max") return `Math.max(${values.join(", ")})`;
  if (name === "min") return `Math.min(${values.join(", ")})`;
  if (name === "guardNonzero") return `guardNonzero(${values[0]}, ${values[1]})`;
  if (name === "guardNonNegative") return `guardNonNegative(${values[0]}, ${values[1]})`;
  if (name === "guardNotInteger") return `guardNotInteger(${values[0]}, ${values[1]})`;
  if (name === "guardValid") return `guardValid(${values[0]}, ${values[1]})`;
  if (name === "fallback") return `fallback(${values[0]}, ${values[1]})`;
  throw new TypeError(`Unknown function: ${JSON.stringify(name)}`);
}

function constantValue(name) {
  if (name === "pi") return Math.PI;
  if (name === "e") return Math.E;
  throw new TypeError(`Unknown constant: ${JSON.stringify(name)}`);
}

function numberLiteral(value) {
  if (Number.isNaN(value)) return "NaN";
  if (value === Infinity) return "Infinity";
  if (value === -Infinity) return "-Infinity";
  return JSON.stringify(value);
}

function validateJsIdentifier(value, name) {
  if (typeof value !== "string" || !/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(value) || JS_RESERVED_WORDS.has(value)) {
    throw new TypeError(`${name} must be a JavaScript identifier`);
  }
  return value;
}

const JS_RESERVED_WORDS = new Set([
  "await",
  "break",
  "case",
  "catch",
  "class",
  "const",
  "continue",
  "debugger",
  "default",
  "delete",
  "do",
  "else",
  "export",
  "extends",
  "finally",
  "for",
  "function",
  "if",
  "import",
  "in",
  "instanceof",
  "let",
  "new",
  "return",
  "super",
  "switch",
  "this",
  "throw",
  "try",
  "typeof",
  "var",
  "void",
  "while",
  "with",
  "yield",
]);