Back to writing

Solving Equation Dependencies with Graphs

January 29, 2023

When equations depend on each other, the real problem is dependency ordering. A graph plus topological sort makes that explicit.

If one equation depends on the result of another, the hard part is not the math. It is the order.

That is what makes this a graph problem.

The actual shape of the problem

Take a set of equations like this:

const equations = [
  { variable: "x", equation: "2 * y" },
  { variable: "y", equation: "5" },
  { variable: "s", equation: "y / 3" },
  { variable: "z", equation: "s + 2 * y" },
];

You cannot evaluate x until you know y. You cannot evaluate z until you know both s and y.

Once you say that out loud, the right model is pretty clear:

  • each variable is a node
  • each dependency is an edge

At that point, the problem stops being "How do I keep retrying equations until they work?" and becomes "What is the correct dependency order?"

That is a much better problem to solve.

Build a dependency graph

type Equation = {
  variable: string;
  equation: string;
};
 
type Graph = {
  vertices: string[];
  edges: Array<{ from: string; to: string }>;
};

The main job is extracting dependencies from the right-hand side of each equation. math.js helps here because it gives you an AST you can traverse instead of trying to parse expressions yourself.

function parseEquation(equation: string) {
  const symbols: string[] = [];
  const functions: string[] = [];
 
  math.parse(equation).traverse((node) => {
    if (node.isSymbolNode) symbols.push(node.name);
    if (node.isFunctionNode) functions.push(node.name);
  });
 
  return symbols.filter((symbol) => !functions.includes(symbol));
}

That last line matters because not every symbol you encounter is a variable. Some are function names, and you do not want to accidentally turn sqrt(x) into a dependency on sqrt.

Topological sort is the real unlock

Once the graph exists, a topological sort gives you an evaluation order where every dependency comes before the thing that depends on it.

function topologicalSort(graph: Graph) {
  const sorted: string[] = [];
  const visited: Record<string, boolean> = {};
 
  function visit(vertex: string) {
    if (visited[vertex]) return;
    visited[vertex] = true;
 
    for (const edge of graph.edges.filter((edge) => edge.from === vertex)) {
      visit(edge.to);
    }
 
    sorted.push(vertex);
  }
 
  for (const vertex of graph.vertices) {
    visit(vertex);
  }
 
  return sorted;
}

Then you evaluate equations in that order, passing previously computed values into math.evaluate().

Why this is better than ad hoc solving

Without the graph, people often end up with some version of this strategy:

  • try to evaluate everything
  • skip the ones that fail
  • retry until nothing changes

That can work for tiny inputs, but it hides the real structure of the problem and gets messy fast.

The graph approach is better because it makes the dependency model explicit. That gives you cleaner reasoning, easier debugging, and a solution that scales better as the equation set grows.

One caveat worth calling out

This approach assumes the dependency graph is acyclic.

If x depends on y and y depends on x, there is no valid topological ordering. That is not an implementation detail. It is the system telling you the equations are circular and cannot be solved in this form.

That is another reason the graph model is useful: it does not just help you solve valid inputs. It helps you detect invalid ones cleanly.

The bigger lesson

The interesting part here is not really math.js.

It is the engineering move underneath it: when a problem is really about relationships and ordering, stop fighting that shape. Model the relationships directly and let the algorithm follow from there.