diff --git a/packages/bridge-core/src/enumerate-traversals.ts b/packages/bridge-core/src/enumerate-traversals.ts new file mode 100644 index 00000000..fcbe9fe4 --- /dev/null +++ b/packages/bridge-core/src/enumerate-traversals.ts @@ -0,0 +1,202 @@ +/** + * Enumerate all possible traversal paths through a Bridge. + * + * Every bridge has a finite set of execution paths ("traversals"), + * determined by the wire structure alone — independent of runtime values. + * + * Examples: + * `o <- i.a || i.b catch i.c` → 3 traversals (primary, fallback, catch) + * `o <- i.arr[] as a { .data <- a.a ?? a.b }` → 3 traversals + * (empty-array, primary for .data, nullish fallback for .data) + * + * Used for complexity assessment and will integrate into the execution + * engine for monitoring. + */ + +import type { Bridge, Wire, WireFallback } from "./types.ts"; + +// ── Public types ──────────────────────────────────────────────────────────── + +/** + * A single traversal path through a bridge wire. + */ +export interface TraversalEntry { + /** Stable identifier for this traversal path. */ + id: string; + /** Index of the originating wire in `bridge.wires` (-1 for synthetic entries like empty-array). */ + wireIndex: number; + /** Target path segments from the wire's `to` NodeRef. */ + target: string[]; + /** Classification of this traversal path. */ + kind: + | "primary" + | "fallback" + | "catch" + | "empty-array" + | "then" + | "else" + | "const"; + /** Fallback chain index (only when kind is `"fallback"`). */ + fallbackIndex?: number; + /** Gate type (only when kind is `"fallback"`): `"falsy"` for `||`, `"nullish"` for `??`. */ + gateType?: "falsy" | "nullish"; +} + +// ── Helpers ───────────────────────────────────────────────────────────────── + +function pathKey(path: string[]): string { + return path.length > 0 ? path.join(".") : "*"; +} + +function hasCatch(w: Wire): boolean { + if ("value" in w) return false; + return ( + w.catchFallback != null || + w.catchFallbackRef != null || + w.catchControl != null + ); +} + +/** + * True when the wire is an array-source wire that simply feeds an array + * iteration scope without any fallback/catch choices of its own. + * + * Such wires always execute (to fetch the array), so they are not a + * traversal "choice". The separate `empty-array` entry already covers + * the "no elements" outcome. + */ +function isPlainArraySourceWire( + w: Wire, + arrayIterators: Record | undefined, +): boolean { + if (!arrayIterators) return false; + if (!("from" in w)) return false; + if (w.from.element) return false; + const targetPath = w.to.path.join("."); + if (!(targetPath in arrayIterators)) return false; + return !w.fallbacks?.length && !hasCatch(w); +} + +function addFallbackEntries( + entries: TraversalEntry[], + base: string, + wireIndex: number, + target: string[], + fallbacks: WireFallback[] | undefined, +): void { + if (!fallbacks) return; + for (let i = 0; i < fallbacks.length; i++) { + entries.push({ + id: `${base}/fallback:${i}`, + wireIndex, + target, + kind: "fallback", + fallbackIndex: i, + gateType: fallbacks[i].type, + }); + } +} + +function addCatchEntry( + entries: TraversalEntry[], + base: string, + wireIndex: number, + target: string[], + w: Wire, +): void { + if (hasCatch(w)) { + entries.push({ id: `${base}/catch`, wireIndex, target, kind: "catch" }); + } +} + +// ── Main function ─────────────────────────────────────────────────────────── + +/** + * Enumerate every possible traversal path through a bridge. + * + * Returns a flat list of {@link TraversalEntry} objects, one per + * unique code-path through the bridge's wires. The total length + * of the returned array is a useful proxy for bridge complexity. + */ +export function enumerateTraversalIds(bridge: Bridge): TraversalEntry[] { + const entries: TraversalEntry[] = []; + + // Track per-target occurrence counts for disambiguation when + // multiple wires write to the same target (overdefinition). + const targetCounts = new Map(); + + for (let i = 0; i < bridge.wires.length; i++) { + const w = bridge.wires[i]; + const target = w.to.path; + const tKey = pathKey(target); + + // Disambiguate overdefined targets (same target written by >1 wire). + const seen = targetCounts.get(tKey) ?? 0; + targetCounts.set(tKey, seen + 1); + const base = seen > 0 ? `${tKey}#${seen}` : tKey; + + // ── Constant wire ─────────────────────────────────────────────── + if ("value" in w) { + entries.push({ id: `${base}/const`, wireIndex: i, target, kind: "const" }); + continue; + } + + // ── Pull wire ─────────────────────────────────────────────────── + if ("from" in w) { + // Skip plain array source wires — they always execute and the + // separate "empty-array" entry covers the "no elements" path. + if (!isPlainArraySourceWire(w, bridge.arrayIterators)) { + entries.push({ + id: `${base}/primary`, + wireIndex: i, + target, + kind: "primary", + }); + addFallbackEntries(entries, base, i, target, w.fallbacks); + addCatchEntry(entries, base, i, target, w); + } + continue; + } + + // ── Conditional (ternary) wire ────────────────────────────────── + if ("cond" in w) { + entries.push({ id: `${base}/then`, wireIndex: i, target, kind: "then" }); + entries.push({ id: `${base}/else`, wireIndex: i, target, kind: "else" }); + addFallbackEntries(entries, base, i, target, w.fallbacks); + addCatchEntry(entries, base, i, target, w); + continue; + } + + // ── condAnd / condOr (logical binary) ─────────────────────────── + entries.push({ + id: `${base}/primary`, + wireIndex: i, + target, + kind: "primary", + }); + if ("condAnd" in w) { + addFallbackEntries(entries, base, i, target, w.fallbacks); + addCatchEntry(entries, base, i, target, w); + } else { + // condOr + const wo = w as Extract; + addFallbackEntries(entries, base, i, target, wo.fallbacks); + addCatchEntry(entries, base, i, target, w); + } + } + + // ── Array iterators — each scope adds an "empty-array" path ───── + if (bridge.arrayIterators) { + for (const key of Object.keys(bridge.arrayIterators)) { + const id = key ? `${key}/empty-array` : "*/empty-array"; + entries.push({ + id, + wireIndex: -1, + target: key ? key.split(".") : [], + kind: "empty-array", + }); + } + } + + return entries; +} diff --git a/packages/bridge-core/src/index.ts b/packages/bridge-core/src/index.ts index db41eb9a..6753dc58 100644 --- a/packages/bridge-core/src/index.ts +++ b/packages/bridge-core/src/index.ts @@ -82,6 +82,11 @@ export type { WireFallback, } from "./types.ts"; +// ── Traversal enumeration ─────────────────────────────────────────────────── + +export { enumerateTraversalIds } from "./enumerate-traversals.ts"; +export type { TraversalEntry } from "./enumerate-traversals.ts"; + // ── Utilities ─────────────────────────────────────────────────────────────── export { parsePath } from "./utils.ts"; diff --git a/packages/bridge/test/enumerate-traversals.test.ts b/packages/bridge/test/enumerate-traversals.test.ts new file mode 100644 index 00000000..4885029f --- /dev/null +++ b/packages/bridge/test/enumerate-traversals.test.ts @@ -0,0 +1,322 @@ +import { describe, test } from "node:test"; +import assert from "node:assert/strict"; +import { parseBridge } from "@stackables/bridge-parser"; +import { enumerateTraversalIds } from "@stackables/bridge-core"; +import type { Bridge, TraversalEntry } from "@stackables/bridge-core"; + +function getBridge(source: string): Bridge { + const doc = parseBridge(source); + const bridge = doc.instructions.find( + (i): i is Bridge => i.kind === "bridge", + ); + assert.ok(bridge, "expected a bridge instruction"); + return bridge; +} + +function ids(entries: TraversalEntry[]): string[] { + return entries.map((e) => e.id); +} + +// ── Simple wires ──────────────────────────────────────────────────────────── + +describe("enumerateTraversalIds", () => { + test("simple pull wire — 1 traversal (primary)", () => { + const bridge = getBridge(`version 1.5 +bridge Query.demo { + with api + with input as i + with output as o + api.q <- i.q + o.result <- api.label +}`); + const entries = enumerateTraversalIds(bridge); + const primaries = entries.filter((e) => e.kind === "primary"); + assert.ok(primaries.length >= 2, "at least 2 primary wires"); + assert.ok( + entries.every((e) => e.kind === "primary"), + "no fallbacks or catches", + ); + }); + + test("constant wire — 1 traversal (const)", () => { + const bridge = getBridge(`version 1.5 +bridge Query.demo { + with api + with output as o + api.mode = "fast" + o.result <- api.label +}`); + const entries = enumerateTraversalIds(bridge); + const consts = entries.filter((e) => e.kind === "const"); + assert.equal(consts.length, 1); + assert.ok(consts[0].id.endsWith("/const")); + }); + + // ── Fallback chains ─────────────────────────────────────────────────────── + + test("|| fallback — 2 traversals (primary + fallback)", () => { + const bridge = getBridge(`version 1.5 +bridge Query.demo { + with a + with b + with input as i + with output as o + a.q <- i.q + b.q <- i.q + o.label <- a.label || b.label +}`); + const entries = enumerateTraversalIds(bridge); + const labelEntries = entries.filter((e) => + e.target.includes("label") && e.target.length === 1, + ); + assert.equal(labelEntries.length, 2); + assert.equal(labelEntries[0].kind, "primary"); + assert.equal(labelEntries[1].kind, "fallback"); + assert.equal(labelEntries[1].gateType, "falsy"); + assert.equal(labelEntries[1].fallbackIndex, 0); + }); + + test("?? fallback — 2 traversals (primary + nullish fallback)", () => { + const bridge = getBridge(`version 1.5 +bridge Query.demo { + with api + with input as i + with output as o + api.q <- i.q + o.label <- api.label ?? "default" +}`); + const entries = enumerateTraversalIds(bridge); + const labelEntries = entries.filter((e) => + e.target.includes("label") && e.target.length === 1, + ); + assert.equal(labelEntries.length, 2); + assert.equal(labelEntries[0].kind, "primary"); + assert.equal(labelEntries[1].kind, "fallback"); + assert.equal(labelEntries[1].gateType, "nullish"); + }); + + test("|| || — 3 traversals (primary + 2 fallbacks)", () => { + const bridge = getBridge(`version 1.5 +bridge Query.demo { + with a + with b + with input as i + with output as o + a.q <- i.q + b.q <- i.q + o.label <- a.label || b.label || "fallback" +}`); + const entries = enumerateTraversalIds(bridge); + const labelEntries = entries.filter((e) => + e.target.includes("label") && e.target.length === 1, + ); + assert.equal(labelEntries.length, 3); + assert.equal(labelEntries[0].kind, "primary"); + assert.equal(labelEntries[1].kind, "fallback"); + assert.equal(labelEntries[1].fallbackIndex, 0); + assert.equal(labelEntries[2].kind, "fallback"); + assert.equal(labelEntries[2].fallbackIndex, 1); + }); + + // ── Catch ───────────────────────────────────────────────────────────────── + + test("catch — 2 traversals (primary + catch)", () => { + const bridge = getBridge(`version 1.5 +bridge Query.demo { + with api + with input as i + with output as o + api.q <- i.q + o.lat <- api.lat catch 0 +}`); + const entries = enumerateTraversalIds(bridge); + const latEntries = entries.filter((e) => + e.target.includes("lat") && e.target.length === 1, + ); + assert.equal(latEntries.length, 2); + assert.equal(latEntries[0].kind, "primary"); + assert.equal(latEntries[1].kind, "catch"); + }); + + // ── Problem statement example: || + catch ───────────────────────────────── + + test("o <- i.a || i.b catch i.c — 3 traversals", () => { + const bridge = getBridge(`version 1.5 +bridge Query.demo { + with a + with b + with input as i + with output as o + a.q <- i.q + b.q <- i.q + o.result <- a.value || b.value catch i.fallback +}`); + const entries = enumerateTraversalIds(bridge); + const resultEntries = entries.filter((e) => + e.target.includes("result") && e.target.length === 1, + ); + assert.equal(resultEntries.length, 3); + assert.equal(resultEntries[0].kind, "primary"); + assert.equal(resultEntries[1].kind, "fallback"); + assert.equal(resultEntries[2].kind, "catch"); + }); + + // ── Array iterators ─────────────────────────────────────────────────────── + + test("array block — adds empty-array traversal", () => { + const bridge = getBridge(`version 1.5 +bridge Query.demo { + with api + with output as o + o <- api.items[] as it { + .id <- it.id + .name <- it.name + } +}`); + const entries = enumerateTraversalIds(bridge); + const emptyArr = entries.filter((e) => e.kind === "empty-array"); + assert.equal(emptyArr.length, 1); + assert.equal(emptyArr[0].wireIndex, -1); + }); + + // ── Problem statement example: array + ?? ───────────────────────────────── + + test("o.out <- i.array[] as a { .data <- a.a ?? a.b } — 3 traversals", () => { + const bridge = getBridge(`version 1.5 +bridge Query.demo { + with api + with output as o + o <- api.items[] as a { + .data <- a.a ?? a.b + } +}`); + const entries = enumerateTraversalIds(bridge); + // Should have: empty-array + primary(.data) + fallback(.data) + assert.equal(entries.length, 3); + const emptyArr = entries.filter((e) => e.kind === "empty-array"); + assert.equal(emptyArr.length, 1); + const dataEntries = entries.filter((e) => + e.target.join(".").includes("data"), + ); + assert.equal(dataEntries.length, 2); + assert.equal(dataEntries[0].kind, "primary"); + assert.equal(dataEntries[1].kind, "fallback"); + assert.equal(dataEntries[1].gateType, "nullish"); + }); + + // ── Nested arrays ───────────────────────────────────────────────────────── + + test("nested array blocks — 2 empty-array entries", () => { + const bridge = getBridge(`version 1.5 +bridge Query.demo { + with api + with output as o + o <- api.journeys[] as j { + .label <- j.label + .legs <- j.legs[] as l { + .name <- l.name + } + } +}`); + const entries = enumerateTraversalIds(bridge); + const emptyArr = entries.filter((e) => e.kind === "empty-array"); + assert.equal(emptyArr.length, 2, "two array scopes"); + }); + + // ── IDs are unique ──────────────────────────────────────────────────────── + + test("all IDs within a bridge are unique", () => { + const bridge = getBridge(`version 1.5 +bridge Query.demo { + with a + with b + with input as i + with output as o + a.q <- i.q + b.q <- i.q + o.label <- a.label || b.label catch "none" + o.score <- a.score ?? 0 +}`); + const entries = enumerateTraversalIds(bridge); + const allIds = ids(entries); + const unique = new Set(allIds); + assert.equal(unique.size, allIds.length, `IDs must be unique: ${JSON.stringify(allIds)}`); + }); + + // ── TraversalEntry shape ────────────────────────────────────────────────── + + test("entries have correct structure", () => { + const bridge = getBridge(`version 1.5 +bridge Query.demo { + with api + with input as i + with output as o + api.q <- i.q + o.result <- api.value || "default" catch 0 +}`); + const entries = enumerateTraversalIds(bridge); + for (const entry of entries) { + assert.ok(typeof entry.id === "string", "id is string"); + assert.ok(typeof entry.wireIndex === "number", "wireIndex is number"); + assert.ok(Array.isArray(entry.target), "target is array"); + assert.ok(typeof entry.kind === "string", "kind is string"); + } + const fb = entries.find((e) => e.kind === "fallback"); + assert.ok(fb, "should have a fallback entry"); + assert.equal(fb!.fallbackIndex, 0); + assert.equal(fb!.gateType, "falsy"); + }); + + // ── Conditional wire ────────────────────────────────────────────────────── + + test("conditional (ternary) wire — 2 traversals (then + else)", () => { + const bridge = getBridge(`version 1.5 +bridge Query.demo { + with api + with input as i + with output as o + api.q <- i.q + o.label <- i.flag ? api.a : api.b +}`); + const entries = enumerateTraversalIds(bridge); + const labelEntries = entries.filter((e) => + e.target.includes("label") && e.target.length === 1, + ); + assert.ok(labelEntries.length >= 2, "at least then + else"); + const then = labelEntries.find((e) => e.kind === "then"); + const els = labelEntries.find((e) => e.kind === "else"); + assert.ok(then, "should have a then entry"); + assert.ok(els, "should have an else entry"); + }); + + // ── Total count is a complexity proxy ───────────────────────────────────── + + test("total traversal count reflects complexity", () => { + const simple = getBridge(`version 1.5 +bridge Query.simple { + with api + with output as o + o.value <- api.value +}`); + const complex = getBridge(`version 1.5 +bridge Query.complex { + with a + with b + with input as i + with output as o + a.q <- i.q + b.q <- i.q + o.x <- a.x || b.x catch "none" + o.y <- a.y ?? b.y + o.items <- a.items[] as it { + .name <- it.name || "anon" + } +}`); + const simpleCount = enumerateTraversalIds(simple).length; + const complexCount = enumerateTraversalIds(complex).length; + assert.ok( + complexCount > simpleCount, + `complex (${complexCount}) should exceed simple (${simpleCount})`, + ); + }); +});