Skip to content

Commit 4adf6c5

Browse files
committed
Attach outputs to nodes; rework solver union/variant model
Move tool output specs from AppMeta to the NodeMeta of the node that owns them. The host node is the implicit trigger; gating is read off its tree position by gateContext (host -> root). Replaces an earlier, never-shipped AppMeta-flat design whose pass-coupling and dual name-index made simplify/flatten/canonicalize all name-aware. IR + passes - NodeMeta.outputs?: Output[]; Output gains optional? (frontend-declared, ORed with structural gating). AppMeta.outputs and Output.triggers gone. - simplify.mergeMeta concatenates outputs (and carries defaultValue); PassContext / collectReferencedNames / name-preservation.test.ts gone. flatten/canonicalize/remove-empty back to plain apply(expr). Solver - ResolvedOutput.branchCondition is GateAtom[][]: { present, binding } or { variant, binding, variant }. Backends read the predicate kind off bindings.get(binding).type.kind. - SolveOptions.appMeta removed; solve(expr) finds outputs in the IR. - resolve is the single node -> binding map (no side-channel). Variant gates recovered via alts.indexOf(prev) -> union.variants[i].name. - Union/variant rework: tagVariant -> pure taggedVariantType; fixes the field name for collapsed single-field arms (seq(lit("convert"), path{src}) now boxes as { src: scalar }, not { convert: scalar }). nodeToVariant side-channel and allLiterals special-case dropped. Multi-field arm bindings are retyped explicitly so they agree with the union. Boutiques frontend - attachOutputs runs inside parseDescriptor so subcommand descriptors carry their own outputs. pickOutputHost = LCA of referenced input nodes (real LCA, not the earlier "single -> node, else root" heuristic); literal-only or unresolved refs -> root. Refs to optional inputs get fallback: "". output-files[].optional -> Output.optional. Backend - backend/resolve-output-tokens.ts: planOutput / outputGuard / GuardClause / OutputEmitPlan. Storage-model-agnostic; no codegen changes yet (Phase 3.5 will wire it in). 479 tests pass.
1 parent f86fba1 commit 4adf6c5

22 files changed

Lines changed: 1641 additions & 74 deletions

packages/core/src/backend/index.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,18 @@ export { collectNamedTypes, resolveTypeName } from "./collect-named-types.js";
88
export { findDoc } from "./find-doc.js";
99
export { findStructNode } from "./find-struct-node.js";
1010
export { resolveFieldBinding } from "./resolve-field-binding.js";
11+
export type {
12+
GuardClause,
13+
OutputCardinality,
14+
OutputEmitPlan,
15+
OutputGuard,
16+
} from "./resolve-output-tokens.js";
17+
export {
18+
compactTokens,
19+
outputCardinality,
20+
outputGuard,
21+
planOutput,
22+
} from "./resolve-output-tokens.js";
1123
export { Scope } from "./scope.js";
1224
export type { JsonSchema } from "./schema/index.js";
1325
export { generateSchema, JsonSchemaBackend } from "./schema/index.js";
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import { describe, expect, it } from "vitest";
2+
import type { ResolvedOutput, ResolvedToken } from "../bindings/index.js";
3+
import {
4+
compactTokens,
5+
outputCardinality,
6+
outputGuard,
7+
planOutput,
8+
} from "./resolve-output-tokens.js";
9+
10+
function output(overrides: Partial<ResolvedOutput> = {}): ResolvedOutput {
11+
return {
12+
name: "out",
13+
tokens: [],
14+
branchCondition: [[]],
15+
listScope: [],
16+
optional: false,
17+
...overrides,
18+
};
19+
}
20+
21+
describe("outputCardinality", () => {
22+
it("returns 'always' when not optional and no listScope", () => {
23+
expect(outputCardinality(output())).toBe("always");
24+
});
25+
26+
it("returns 'optional' when optional and no listScope", () => {
27+
expect(outputCardinality(output({ optional: true }))).toBe("optional");
28+
});
29+
30+
it("returns 'list' when listScope present and not optional", () => {
31+
expect(outputCardinality(output({ listScope: ["b1"] }))).toBe("list");
32+
});
33+
34+
it("returns 'list-optional' when both listScope and optional", () => {
35+
expect(outputCardinality(output({ listScope: ["b1"], optional: true }))).toBe(
36+
"list-optional",
37+
);
38+
});
39+
});
40+
41+
describe("outputGuard", () => {
42+
it("returns 'always' for empty branchCondition", () => {
43+
expect(outputGuard(output({ branchCondition: [] }))).toEqual({ kind: "always" });
44+
});
45+
46+
it("returns 'always' for single empty path", () => {
47+
expect(outputGuard(output({ branchCondition: [[]] }))).toEqual({ kind: "always" });
48+
});
49+
50+
it("returns 'any-of' for non-empty branchCondition", () => {
51+
const guard = outputGuard(
52+
output({
53+
branchCondition: [
54+
[{ kind: "present", binding: "b1" }],
55+
[
56+
{ kind: "present", binding: "b2" },
57+
{ kind: "variant", binding: "u1", variant: "add" },
58+
],
59+
],
60+
}),
61+
);
62+
expect(guard).toEqual({
63+
kind: "any-of",
64+
clauses: [
65+
{ atoms: [{ kind: "present", binding: "b1" }] },
66+
{
67+
atoms: [
68+
{ kind: "present", binding: "b2" },
69+
{ kind: "variant", binding: "u1", variant: "add" },
70+
],
71+
},
72+
],
73+
});
74+
});
75+
76+
it("does NOT collapse a single non-empty path", () => {
77+
const guard = outputGuard(output({ branchCondition: [[{ kind: "present", binding: "b1" }]] }));
78+
expect(guard).toEqual({
79+
kind: "any-of",
80+
clauses: [{ atoms: [{ kind: "present", binding: "b1" }] }],
81+
});
82+
});
83+
});
84+
85+
describe("compactTokens", () => {
86+
it("merges consecutive literal tokens", () => {
87+
const tokens: ResolvedToken[] = [
88+
{ kind: "literal", value: "a" },
89+
{ kind: "literal", value: "b" },
90+
{ kind: "literal", value: "c" },
91+
];
92+
expect(compactTokens(tokens)).toEqual([{ kind: "literal", value: "abc" }]);
93+
});
94+
95+
it("preserves refs and merges literals around them", () => {
96+
const tokens: ResolvedToken[] = [
97+
{ kind: "literal", value: "pre-" },
98+
{ kind: "literal", value: "fix" },
99+
{ kind: "ref", binding: "b1" },
100+
{ kind: "literal", value: ".out" },
101+
];
102+
expect(compactTokens(tokens)).toEqual([
103+
{ kind: "literal", value: "pre-fix" },
104+
{ kind: "ref", binding: "b1" },
105+
{ kind: "literal", value: ".out" },
106+
]);
107+
});
108+
109+
it("returns empty array for empty input", () => {
110+
expect(compactTokens([])).toEqual([]);
111+
});
112+
});
113+
114+
describe("planOutput", () => {
115+
it("packages cardinality, guard, listScope, and compacted tokens", () => {
116+
const resolved = output({
117+
name: "log",
118+
tokens: [
119+
{ kind: "literal", value: "log-" },
120+
{ kind: "literal", value: "file" },
121+
{ kind: "ref", binding: "b1" },
122+
],
123+
branchCondition: [[{ kind: "present", binding: "b2" }]],
124+
listScope: ["b3"],
125+
optional: true,
126+
});
127+
const plan = planOutput(resolved);
128+
expect(plan.name).toBe("log");
129+
expect(plan.cardinality).toBe("list-optional");
130+
expect(plan.guard).toEqual({
131+
kind: "any-of",
132+
clauses: [{ atoms: [{ kind: "present", binding: "b2" }] }],
133+
});
134+
expect(plan.listScope).toEqual(["b3"]);
135+
expect(plan.tokens).toEqual([
136+
{ kind: "literal", value: "log-file" },
137+
{ kind: "ref", binding: "b1" },
138+
]);
139+
expect(plan.resolved).toBe(resolved);
140+
});
141+
142+
it("preserves ref token's stripExtensions and fallback through compacting", () => {
143+
const resolved = output({
144+
tokens: [
145+
{ kind: "ref", binding: "b1", stripExtensions: [".nii"], fallback: "x" },
146+
],
147+
});
148+
const plan = planOutput(resolved);
149+
expect(plan.tokens[0]).toEqual({
150+
kind: "ref",
151+
binding: "b1",
152+
stripExtensions: [".nii"],
153+
fallback: "x",
154+
});
155+
});
156+
});
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import type { BindingId, GateAtom, ResolvedOutput, ResolvedToken } from "../bindings/index.js";
2+
3+
/**
4+
* Cardinality of a resolved output: how many path values it produces and
5+
* whether the user must handle a null/missing case.
6+
*
7+
* - `always`: exactly one path, never null. Type as `path`.
8+
* - `optional`: zero or one path. Type as `path | null`.
9+
* - `list`: zero or more paths (per listScope iteration). Type as `path[]`.
10+
* - `list-optional`: zero or more paths but the whole output may be skipped.
11+
* Type as `path[] | null`.
12+
*/
13+
export type OutputCardinality = "always" | "optional" | "list" | "list-optional";
14+
15+
/** Compute cardinality from `listScope` and `optional` on a ResolvedOutput. */
16+
export function outputCardinality(resolved: ResolvedOutput): OutputCardinality {
17+
const isList = resolved.listScope.length > 0;
18+
if (isList && resolved.optional) return "list-optional";
19+
if (isList) return "list";
20+
if (resolved.optional) return "optional";
21+
return "always";
22+
}
23+
24+
/**
25+
* Guard expression controlling whether the output emits.
26+
*
27+
* `always` means the output emits unconditionally (no gating ancestors).
28+
* `any-of` is a disjunction of conjunctions: emit if any clause's atoms all
29+
* hold. Each `GateAtom` is either `present` (the bound parameter is non-null /
30+
* `true` / `> 0`, per its binding's type) or `variant` (a union selected a
31+
* particular arm).
32+
*/
33+
export type OutputGuard =
34+
| { kind: "always" }
35+
| { kind: "any-of"; clauses: GuardClause[] };
36+
37+
export interface GuardClause {
38+
/** All of these conditions must hold for this clause to fire. */
39+
atoms: GateAtom[];
40+
}
41+
42+
/**
43+
* Reduce `branchCondition` to a guard expression. An empty branchCondition or
44+
* a single empty path collapses to `always`; otherwise we keep the disjunction
45+
* of conjunctions.
46+
*/
47+
export function outputGuard(resolved: ResolvedOutput): OutputGuard {
48+
const bc = resolved.branchCondition;
49+
if (bc.length === 0) return { kind: "always" };
50+
if (bc.length === 1 && bc[0]!.length === 0) return { kind: "always" };
51+
return {
52+
kind: "any-of",
53+
clauses: bc.map((atoms) => ({ atoms })),
54+
};
55+
}
56+
57+
/**
58+
* Merge consecutive literal tokens. Backends that emit string concatenation
59+
* benefit from a shorter token stream; backends that template each token
60+
* individually can ignore this and use `resolved.tokens` directly.
61+
*/
62+
export function compactTokens(tokens: ResolvedToken[]): ResolvedToken[] {
63+
const out: ResolvedToken[] = [];
64+
for (const tok of tokens) {
65+
const last = out[out.length - 1];
66+
if (tok.kind === "literal" && last && last.kind === "literal") {
67+
out[out.length - 1] = { kind: "literal", value: last.value + tok.value };
68+
} else {
69+
out.push(tok);
70+
}
71+
}
72+
return out;
73+
}
74+
75+
/**
76+
* Single point of entry: convert a ResolvedOutput to a plan whose fields map
77+
* directly to the codegen patterns described in `memory/design_outputs.md`.
78+
*
79+
* Backends typically need:
80+
* - `cardinality` to decide the field type (`path | null` vs `path[]`, etc.).
81+
* - `guard` to render the if-condition gating the output assignment.
82+
* - `listScope` to render the for-loop when iterating per repeat-binding.
83+
* - `tokens` to render the path expression. Refs with a `fallback` should be
84+
* emitted as `ref ?? fallback` (or the language equivalent) so unreachable
85+
* refs naturally resolve to their fallback at runtime.
86+
*/
87+
export interface OutputEmitPlan {
88+
name: string;
89+
cardinality: OutputCardinality;
90+
guard: OutputGuard;
91+
listScope: BindingId[];
92+
tokens: ResolvedToken[];
93+
resolved: ResolvedOutput;
94+
}
95+
96+
export function planOutput(resolved: ResolvedOutput): OutputEmitPlan {
97+
return {
98+
name: resolved.name,
99+
cardinality: outputCardinality(resolved),
100+
guard: outputGuard(resolved),
101+
listScope: resolved.listScope,
102+
tokens: compactTokens(resolved.tokens),
103+
resolved,
104+
};
105+
}

packages/core/src/bindings/binding.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { Expr } from "../ir/index.js";
2+
import type { ResolvedOutput } from "./resolved-output.js";
23
import type { BoundType } from "./types.js";
34

45
export type BindingId = string;
@@ -12,9 +13,24 @@ export interface Binding {
1213

1314
export type BindingRegistry = Map<BindingId, Binding>;
1415

16+
export type OutputDiagnosticLevel = "error" | "warning";
17+
18+
export interface OutputDiagnostic {
19+
output: string;
20+
message: string;
21+
level: OutputDiagnosticLevel;
22+
}
23+
24+
export interface OutputValidationResult {
25+
errors: OutputDiagnostic[];
26+
warnings: OutputDiagnostic[];
27+
}
28+
1529
export interface SolveResult {
1630
bindings: BindingRegistry;
1731
resolve: (node: Expr) => Binding | undefined;
32+
outputs: ResolvedOutput[];
33+
outputDiagnostics: OutputValidationResult;
1834
}
1935

2036
export function createRegistry(): BindingRegistry {
Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,13 @@
1-
export type { Binding, BindingId, BindingRegistry, SolveResult } from "./binding.js";
1+
export type {
2+
Binding,
3+
BindingId,
4+
BindingRegistry,
5+
OutputDiagnostic,
6+
OutputDiagnosticLevel,
7+
OutputValidationResult,
8+
SolveResult,
9+
} from "./binding.js";
210
export { createRegistry } from "./binding.js";
11+
export type { GateAtom, ResolvedOutput, ResolvedToken } from "./resolved-output.js";
312
export type { BoundType, BoundVariant } from "./types.js";
413
export { formatSolveResult } from "./format.js";
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import type { Documentation, MediaTypeIdentifier } from "../ir/types.js";
2+
import type { BindingId } from "./binding.js";
3+
4+
/**
5+
* Resolved token in an output path template. Refs point at solved bindings;
6+
* literals are emitted verbatim.
7+
*/
8+
export type ResolvedToken =
9+
| { kind: "literal"; value: string }
10+
| {
11+
kind: "ref";
12+
binding: BindingId;
13+
stripExtensions?: string[];
14+
fallback?: string;
15+
};
16+
17+
/**
18+
* One condition that must hold for an output to fire.
19+
*
20+
* - `present`: the bound parameter is "active" - non-null for an `optional`,
21+
* `true` for a `bool`, `> 0` for a `count`. The backend reads the predicate
22+
* off `bindings.get(binding).type.kind`.
23+
* - `variant`: the union bound by `binding` selected the variant named
24+
* `variant` (the output is hosted inside that alternative arm).
25+
*/
26+
export type GateAtom =
27+
| { kind: "present"; binding: BindingId }
28+
| { kind: "variant"; binding: BindingId; variant: string };
29+
30+
/**
31+
* Output specification translated against the binding registry.
32+
*
33+
* - `branchCondition`: disjunction (outer) of conjunctions (inner) of
34+
* `GateAtom`s. An output attached to a single host node has exactly one
35+
* disjunct - the host's branch path; the disjunctive shape is kept so
36+
* several hosts producing the same output can be merged later.
37+
* - `listScope`: repeat-ancestor (`list`) bindings of the host. The output
38+
* emits once per iteration of the innermost listed binding.
39+
* - `optional`: true when the branch path is non-empty (the host is gated).
40+
*/
41+
export interface ResolvedOutput {
42+
name: string;
43+
doc?: Documentation;
44+
tokens: ResolvedToken[];
45+
branchCondition: GateAtom[][];
46+
listScope: BindingId[];
47+
optional: boolean;
48+
mediaTypes?: MediaTypeIdentifier[];
49+
}

0 commit comments

Comments
 (0)