@aldus-runtime/gate-engine 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/binding.ts ADDED
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Binding a decision to exact inputs (architecture contract §13).
3
+ *
4
+ * Contract §3.6: a decision must be "tied to exact inputs". §13.1 and §13.2 turn that into a
5
+ * requirement — a Content Freeze is void once the content changes, and a TTS authorization "MUST
6
+ * be invalidated if any bound value changes". `GateDecision.subjectHashes` is the mechanism.
7
+ *
8
+ * Two design choices here are load-bearing.
9
+ *
10
+ * **The stored hashes are the raw content digests, not digests of `key + value`.** Hashing the
11
+ * pair would bind the key association more tightly, but it would also make a `subjectHash`
12
+ * unmatchable against an `ArtifactRef.sha256`, and §20 requires production trace to answer which
13
+ * inputs produced a result. Interoperability with the artifact record is worth more than the
14
+ * marginal strength, because the key association is recovered below anyway.
15
+ *
16
+ * **Comparison is over a sorted multiset, and there is no sidecar record of key-to-hash.** A
17
+ * separate record mapping keys to hashes could drift from the decision it describes, and a
18
+ * safety check that depends on two records agreeing fails open the day they disagree. Instead the
19
+ * check is a direct comparison against `subjectHashes`, and the key-level explanation is
20
+ * *derived* by diffing current subjects against that same list. If the explanation is imperfect
21
+ * the check is still exact — the ordering that matters.
22
+ */
23
+
24
+ import { createHash } from "node:crypto";
25
+
26
+ import type { GateDecision } from "@aldus-runtime/core";
27
+
28
+ import type { ResolvedGateDefinition } from "./definition.js";
29
+ import { GateEngineErrorCodes, gateEngineError } from "./errors.js";
30
+
31
+ /** One named input a gate binds, and the digest of its current value. */
32
+ export interface GateSubject {
33
+ /**
34
+ * What this subject is, e.g. a spoken-text hash or a request plan (contract §13.2).
35
+ *
36
+ * An OPEN string. What a gate binds is adopter process (§4.3), so Core names no subject keys.
37
+ */
38
+ key: string;
39
+ /** Lowercase hex SHA-256 of the subject's current value. */
40
+ sha256: string;
41
+ }
42
+
43
+ /** Matches Core's `sha256Hex`: lowercase only, so digests compare by equality. */
44
+ const SHA256_PATTERN = /^[0-9a-f]{64}$/;
45
+
46
+ /**
47
+ * Digest an arbitrary value for use as a subject.
48
+ *
49
+ * Serialisation is canonical — object keys sorted at every depth — so that two structurally
50
+ * identical settings objects produce the same digest regardless of how they were built. Without
51
+ * that, re-serialising an unchanged voice-settings object in a different key order would read as
52
+ * a changed bound value and void a valid authorization (§13.2).
53
+ */
54
+ export function digestSubjectValue(value: unknown): string {
55
+ return createHash("sha256").update(canonicalJson(value), "utf8").digest("hex");
56
+ }
57
+
58
+ /** Digest raw bytes or text for use as a subject. */
59
+ export function digestBytes(value: string | Uint8Array): string {
60
+ return createHash("sha256").update(value).digest("hex");
61
+ }
62
+
63
+ /** JSON with object keys sorted at every depth. */
64
+ function canonicalJson(value: unknown): string {
65
+ if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null";
66
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
67
+ const entries = Object.entries(value as Record<string, unknown>)
68
+ .filter(([, entryValue]) => entryValue !== undefined)
69
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
70
+ .map(([key, entryValue]) => `${JSON.stringify(key)}:${canonicalJson(entryValue)}`);
71
+ return `{${entries.join(",")}}`;
72
+ }
73
+
74
+ /**
75
+ * Check that the supplied subjects cover exactly what the gate binds, and are well formed.
76
+ *
77
+ * A missing subject is refused rather than treated as unchanged. §13.2 requires the operator to
78
+ * approve every listed value, so an authorization that silently omitted one would bind less than
79
+ * the contract requires while still reading as valid.
80
+ *
81
+ * @throws {AldusError} `ALDUS_GATE_SUBJECTS_INCOMPLETE`
82
+ */
83
+ export function assertSubjectsCover(
84
+ gate: ResolvedGateDefinition,
85
+ subjects: readonly GateSubject[],
86
+ ): void {
87
+ const supplied = new Map<string, string>();
88
+ for (const subject of subjects) {
89
+ if (!SHA256_PATTERN.test(subject.sha256)) {
90
+ throw gateEngineError(
91
+ GateEngineErrorCodes.GATE_SUBJECTS_INCOMPLETE,
92
+ `Subject "${subject.key}" for gate "${gate.gateId}" is not a lowercase hex SHA-256. ` +
93
+ "Mixed-case or truncated digests would compare unequal to an identical value.",
94
+ { category: "validation", details: { gateId: gate.gateId, key: subject.key } },
95
+ );
96
+ }
97
+ if (supplied.has(subject.key)) {
98
+ throw gateEngineError(
99
+ GateEngineErrorCodes.GATE_SUBJECTS_INCOMPLETE,
100
+ `Subject "${subject.key}" was supplied twice for gate "${gate.gateId}".`,
101
+ { category: "validation", details: { gateId: gate.gateId, key: subject.key } },
102
+ );
103
+ }
104
+ supplied.set(subject.key, subject.sha256);
105
+ }
106
+
107
+ const missing = gate.binds.filter((key) => !supplied.has(key));
108
+ const unexpected = [...supplied.keys()].filter((key) => !gate.binds.includes(key));
109
+
110
+ if (missing.length > 0 || unexpected.length > 0) {
111
+ throw gateEngineError(
112
+ GateEngineErrorCodes.GATE_SUBJECTS_INCOMPLETE,
113
+ `Gate "${gate.gateId}" binds [${gate.binds.join(", ")}], but was given ` +
114
+ `[${[...supplied.keys()].join(", ")}]. Contract §13.2 requires an authorization to bind ` +
115
+ "every listed value; binding a different set is not the same approval.",
116
+ {
117
+ category: "validation",
118
+ details: { gateId: gate.gateId, missing, unexpected, binds: [...gate.binds] },
119
+ },
120
+ );
121
+ }
122
+ }
123
+
124
+ /**
125
+ * The canonical `subjectHashes` for a set of subjects.
126
+ *
127
+ * Sorted so that the order subjects were supplied in cannot change the stored value, and kept as
128
+ * a list rather than a set so two subjects that happen to share a value stay two subjects.
129
+ */
130
+ export function toSubjectHashes(subjects: readonly GateSubject[]): string[] {
131
+ return subjects.map((subject) => subject.sha256).sort();
132
+ }
133
+
134
+ /** Why a decision no longer binds the current inputs. */
135
+ export interface SubjectDrift {
136
+ /** Subject keys whose current digest is not among the approved hashes. */
137
+ changed: string[];
138
+ /** Approved hashes no longer matched by any current subject. */
139
+ orphanedHashes: string[];
140
+ }
141
+
142
+ /**
143
+ * Compare current subjects against what a decision approved.
144
+ *
145
+ * Returns `undefined` when they match exactly. Otherwise names the keys whose values moved, which
146
+ * is what an operator needs in order to know what to re-approve.
147
+ *
148
+ * The key attribution is best-effort by design: it is derived from the hashes rather than stored
149
+ * alongside them, so an exotic case — two subjects sharing one value, one of which changed — may
150
+ * name both. The *detection* is exact regardless, and detection is what §13.2 requires.
151
+ */
152
+ export function detectDrift(
153
+ decision: GateDecision,
154
+ subjects: readonly GateSubject[],
155
+ ): SubjectDrift | undefined {
156
+ const approved = [...decision.subjectHashes].sort();
157
+ const current = toSubjectHashes(subjects);
158
+
159
+ if (approved.length === current.length && approved.every((hash, i) => hash === current[i])) {
160
+ return undefined;
161
+ }
162
+
163
+ const approvedCounts = new Map<string, number>();
164
+ for (const hash of approved) approvedCounts.set(hash, (approvedCounts.get(hash) ?? 0) + 1);
165
+
166
+ const changed: string[] = [];
167
+ for (const subject of subjects) {
168
+ const remaining = approvedCounts.get(subject.sha256) ?? 0;
169
+ if (remaining > 0) approvedCounts.set(subject.sha256, remaining - 1);
170
+ else changed.push(subject.key);
171
+ }
172
+
173
+ const orphanedHashes: string[] = [];
174
+ for (const [hash, count] of approvedCounts) {
175
+ for (let i = 0; i < count; i += 1) orphanedHashes.push(hash);
176
+ }
177
+
178
+ return { changed, orphanedHashes: orphanedHashes.sort() };
179
+ }
@@ -0,0 +1,380 @@
1
+ /**
2
+ * Gate definitions (architecture contract §12, §13).
3
+ *
4
+ * A definition is **configuration**: what a gate binds to, what it depends on, how strongly it
5
+ * blocks, and who may decide it. A {@link GateDecision} is a **record**: what someone decided,
6
+ * when, and against which exact inputs. Keeping them apart is what lets a decision stay a
7
+ * faithful historical fact while the configuration around it evolves.
8
+ *
9
+ * Contract §13 names four gates — Content Freeze (§13.1), Performance Freeze (§13.2), Human Ear
10
+ * (§13.3), Final Release (§13.4). None of them is hardcoded here. §4.2 keeps show-specific
11
+ * process out of Core's reach and §4.3 gives adopters their own gates, so `gateId` and the
12
+ * subject keys are open strings and the contract's four gates are simply the definitions an
13
+ * adopter is most likely to write. The tests construct them to show the model expresses §13, not
14
+ * because the engine knows their names.
15
+ */
16
+
17
+ import type { ActorKind } from "@aldus-runtime/core";
18
+
19
+ import { GateEngineErrorCodes, gateEngineError } from "./errors.js";
20
+
21
+ /**
22
+ * The four quality levels of contract §12.
23
+ *
24
+ * These describe *what kind of judgement* a gate represents, which is independent of how
25
+ * strongly it blocks. §12's own table pairs them freely: a hard gate blocks, an advisory signal
26
+ * does not, and a model-assisted review may do either depending on whether it has been
27
+ * calibrated (§12.1).
28
+ */
29
+ export const GATE_LEVELS = [
30
+ /** Blocks on an objectively testable failure (§12 level 1). */
31
+ "hard_gate",
32
+ /** Reports a possible issue without blocking (§12 level 2). */
33
+ "advisory_signal",
34
+ /** Evaluates meaning, stance, style, or claims under uncertainty (§12 level 3). */
35
+ "model_assisted",
36
+ /** A human owns the judgement, because it is subjective or asymmetric-risk (§12 level 4). */
37
+ "human_oracle",
38
+ ] as const;
39
+
40
+ /** @see GATE_LEVELS */
41
+ export type GateLevel = (typeof GATE_LEVELS)[number];
42
+
43
+ /**
44
+ * Whether a gate stops work or merely reports.
45
+ *
46
+ * Deliberately a two-state enumeration rather than a boolean. Contract §12.1 permits an
47
+ * evaluator to *become* blocking only after calibration, which makes this a promotion with
48
+ * evidence behind it — and a field named `blocking: boolean` invites someone to flip it in a
49
+ * config file without producing any.
50
+ */
51
+ export const GATE_ENFORCEMENTS = ["blocking", "advisory"] as const;
52
+
53
+ /** @see GATE_ENFORCEMENTS */
54
+ export type GateEnforcement = (typeof GATE_ENFORCEMENTS)[number];
55
+
56
+ /**
57
+ * Evidence that a model-assisted evaluator was calibrated before it was allowed to block.
58
+ *
59
+ * Contract §12.1: "An evaluator MAY become blocking only after it is calibrated against
60
+ * human-labeled examples." The metrics themselves belong to WP-10; this is the reference a
61
+ * definition must carry to claim they exist, and {@link validateGateDefinition} refuses a
62
+ * blocking model-assisted gate without one.
63
+ *
64
+ * `scope` matters as much as the numbers. §12.1 lists show, host, voice, model, and script-form
65
+ * scope among what promotion must consider, because an evaluator calibrated on one host says
66
+ * nothing about another. Dimensions are caller-supplied (§4.2), consistent with WP-09's packs.
67
+ */
68
+ export interface PromotionEvidence {
69
+ /** Identifier of the calibration report that justified promotion (WP-10). */
70
+ reportRef: string;
71
+ /** Scope the calibration covers, e.g. `{ host: "example-host", voice: "voice-a" }`. */
72
+ scope: Record<string, string>;
73
+ /** Known blind spots recorded at promotion time (§12.1, §9.3). */
74
+ knownBlindSpots?: string[];
75
+ }
76
+
77
+ /**
78
+ * One configured gate.
79
+ *
80
+ * @see GateDecision for the record a decision on this gate produces.
81
+ */
82
+ export interface GateDefinition {
83
+ /**
84
+ * Identity of the gate.
85
+ *
86
+ * An OPEN string. Contract §13's four gates are examples an adopter configures, not a set Core
87
+ * fixes (§4.2). Do not narrow this to a union.
88
+ */
89
+ gateId: string;
90
+ /** Operator-facing name. */
91
+ title?: string;
92
+ /** What kind of judgement this gate represents (§12). */
93
+ level: GateLevel;
94
+ /** Whether it stops work or merely reports (§12). */
95
+ enforcement: GateEnforcement;
96
+ /**
97
+ * Named subjects a decision on this gate must bind.
98
+ *
99
+ * For a Performance Freeze these are §13.2's list — spoken-text hash, PerformanceScript hash,
100
+ * voice/model/settings, request plan or segment scope, maximum authorized cost — but the keys
101
+ * are caller-supplied strings, because what a gate binds is adopter process (§4.3).
102
+ *
103
+ * A decision that does not cover every key here is refused: §13.2 requires the operator to
104
+ * approve *all* of the listed values, and an authorization missing one of them binds less than
105
+ * the contract requires.
106
+ */
107
+ binds: readonly string[];
108
+ /**
109
+ * Gates whose invalidation invalidates this one (contract §13.1).
110
+ *
111
+ * §13.1 requires a content-changing edit to invalidate the Content Freeze "and downstream
112
+ * approvals". This edge is what "downstream" means, stated per gate rather than inferred, so
113
+ * an adopter's own gates participate in the cascade without the engine guessing an order.
114
+ */
115
+ dependsOn?: readonly string[];
116
+ /**
117
+ * Actor kinds permitted to decide this gate.
118
+ *
119
+ * Defaults to human-only for `human_oracle`, and to any actor otherwise. §13.3 keeps final
120
+ * performance approval human-owned "until a scoped evaluator is demonstrably reliable", and
121
+ * §12 forbids presenting a machine pass as semantic correctness.
122
+ */
123
+ permittedActorKinds?: readonly ActorKind[];
124
+ /**
125
+ * Default for {@link GateDecision.expiresOnChange} on this gate.
126
+ *
127
+ * Defaults to `true`. §13.1 and §13.2 both require invalidation on change, and a gate that
128
+ * silently defaulted to carrying a stale approval forward would be the failure those sections
129
+ * exist to prevent.
130
+ */
131
+ expiresOnChange?: boolean;
132
+ /** Calibration evidence, required when a model-assisted gate is blocking (§12.1). */
133
+ promotionEvidence?: PromotionEvidence;
134
+ /**
135
+ * Operations this gate authorizes, if any.
136
+ *
137
+ * Contract §13.4: "Uploading and making public SHOULD be separate operations." Naming the
138
+ * operations a gate grants is how that separation is expressed — an approval on one gate
139
+ * authorizes exactly the operations it names and nothing else, so a single decision cannot
140
+ * quietly cover both upload and publication.
141
+ */
142
+ grants?: readonly string[];
143
+ }
144
+
145
+ /** A definition with every default resolved. */
146
+ export interface ResolvedGateDefinition extends GateDefinition {
147
+ dependsOn: readonly string[];
148
+ permittedActorKinds: readonly ActorKind[];
149
+ expiresOnChange: boolean;
150
+ grants: readonly string[];
151
+ }
152
+
153
+ /** Actor kinds a gate accepts when the definition does not say. */
154
+ function defaultPermittedActorKinds(level: GateLevel): readonly ActorKind[] {
155
+ // §12 level 4 is "human oracle — owns subjective judgment or asymmetric-risk decisions", and
156
+ // §13.3 keeps final performance approval human-owned. Defaulting these to any actor would let
157
+ // an agent satisfy the one gate the contract most insists a person owns.
158
+ return level === "human_oracle" ? ["human"] : ["human", "agent", "worker", "system"];
159
+ }
160
+
161
+ /**
162
+ * Resolve defaults and refuse an internally inconsistent definition.
163
+ *
164
+ * @throws {AldusError} `ALDUS_GATE_DEFINITION_INVALID`
165
+ */
166
+ export function validateGateDefinition(definition: GateDefinition): ResolvedGateDefinition {
167
+ const fail = (message: string, details: Record<string, unknown> = {}): never => {
168
+ throw gateEngineError(GateEngineErrorCodes.GATE_DEFINITION_INVALID, message, {
169
+ category: "validation",
170
+ details: { gateId: definition.gateId, ...details },
171
+ });
172
+ };
173
+
174
+ if (definition.gateId.trim().length === 0) fail("A gate definition needs a non-empty gateId.");
175
+
176
+ if (definition.binds.length === 0) {
177
+ // A gate binding nothing cannot be invalidated by anything, which makes its approval
178
+ // permanent — the precise failure §13.1 and §13.2 exist to prevent.
179
+ fail(
180
+ "A gate must bind at least one subject. A gate that binds nothing can never be " +
181
+ "invalidated by a change, so its approval would outlive the content it approved " +
182
+ "(contract §13.1, §13.2).",
183
+ );
184
+ }
185
+
186
+ const duplicates = definition.binds.filter(
187
+ (key, index) => definition.binds.indexOf(key) !== index,
188
+ );
189
+ if (duplicates.length > 0) {
190
+ fail(`A gate cannot bind the same subject twice: ${[...new Set(duplicates)].join(", ")}.`, {
191
+ duplicates: [...new Set(duplicates)],
192
+ });
193
+ }
194
+
195
+ if (definition.dependsOn?.includes(definition.gateId) === true) {
196
+ fail("A gate cannot depend on itself.");
197
+ }
198
+
199
+ if (definition.level === "model_assisted" && definition.enforcement === "blocking") {
200
+ if (definition.promotionEvidence === undefined) {
201
+ fail(
202
+ "A model-assisted gate may only block once it has been calibrated against human-labeled " +
203
+ "examples (contract §12.1). Set `promotionEvidence`, or leave the gate advisory. " +
204
+ "Contract §12 forbids presenting a machine pass as semantic correctness.",
205
+ { level: definition.level, enforcement: definition.enforcement },
206
+ );
207
+ }
208
+ }
209
+
210
+ const permittedActorKinds =
211
+ definition.permittedActorKinds ?? defaultPermittedActorKinds(definition.level);
212
+ if (permittedActorKinds.length === 0) {
213
+ fail("A gate that permits no actor kind can never be decided.");
214
+ }
215
+ if (definition.level === "human_oracle" && !permittedActorKinds.includes("human")) {
216
+ fail("A human-oracle gate must permit a human actor (contract §12 level 4, §13.3).", {
217
+ permittedActorKinds: [...permittedActorKinds],
218
+ });
219
+ }
220
+
221
+ return {
222
+ ...definition,
223
+ dependsOn: definition.dependsOn ?? [],
224
+ permittedActorKinds,
225
+ expiresOnChange: definition.expiresOnChange ?? true,
226
+ grants: definition.grants ?? [],
227
+ };
228
+ }
229
+
230
+ /**
231
+ * A validated set of gates and the dependency graph between them.
232
+ *
233
+ * Built once and reused: cycle detection and unknown-dependency checks run at construction, so a
234
+ * misconfiguration surfaces when the registry is assembled rather than when an operator is
235
+ * waiting on an approval.
236
+ */
237
+ export class GateRegistry {
238
+ readonly #gates: Map<string, ResolvedGateDefinition>;
239
+
240
+ private constructor(gates: Map<string, ResolvedGateDefinition>) {
241
+ this.#gates = gates;
242
+ }
243
+
244
+ /**
245
+ * Validate a set of definitions and the graph they form.
246
+ *
247
+ * @throws {AldusError} `ALDUS_GATE_DEFINITION_INVALID` for an invalid or duplicate definition,
248
+ * or an edge naming a gate that does not exist.
249
+ * @throws {AldusError} `ALDUS_GATE_DEPENDENCY_CYCLE` if the dependency edges form a cycle.
250
+ */
251
+ static from(definitions: readonly GateDefinition[]): GateRegistry {
252
+ const gates = new Map<string, ResolvedGateDefinition>();
253
+ for (const definition of definitions) {
254
+ const resolved = validateGateDefinition(definition);
255
+ if (gates.has(resolved.gateId)) {
256
+ throw gateEngineError(
257
+ GateEngineErrorCodes.GATE_DEFINITION_INVALID,
258
+ `Gate "${resolved.gateId}" is defined more than once.`,
259
+ { category: "validation", details: { gateId: resolved.gateId } },
260
+ );
261
+ }
262
+ gates.set(resolved.gateId, resolved);
263
+ }
264
+
265
+ for (const gate of gates.values()) {
266
+ for (const dependency of gate.dependsOn) {
267
+ if (!gates.has(dependency)) {
268
+ throw gateEngineError(
269
+ GateEngineErrorCodes.GATE_DEFINITION_INVALID,
270
+ `Gate "${gate.gateId}" depends on "${dependency}", which is not defined. An edge to ` +
271
+ "a missing gate would silently drop out of the invalidation cascade (§13.1).",
272
+ { category: "validation", details: { gateId: gate.gateId, dependency } },
273
+ );
274
+ }
275
+ }
276
+ }
277
+
278
+ const cycle = findCycle(gates);
279
+ if (cycle !== undefined) {
280
+ throw gateEngineError(
281
+ GateEngineErrorCodes.GATE_DEPENDENCY_CYCLE,
282
+ `Gate dependencies form a cycle: ${cycle.join(" → ")}. Contract §13.1's cascade is only ` +
283
+ 'meaningful over an acyclic graph; with a cycle, "what does this invalidate" has no answer.',
284
+ { category: "validation", details: { cycle } },
285
+ );
286
+ }
287
+
288
+ return new GateRegistry(gates);
289
+ }
290
+
291
+ /** Every gate, in definition order. */
292
+ list(): ResolvedGateDefinition[] {
293
+ return [...this.#gates.values()];
294
+ }
295
+
296
+ /** True if the gate is registered. */
297
+ has(gateId: string): boolean {
298
+ return this.#gates.has(gateId);
299
+ }
300
+
301
+ /** A gate definition, or `undefined` if it is not registered. */
302
+ get(gateId: string): ResolvedGateDefinition | undefined {
303
+ return this.#gates.get(gateId);
304
+ }
305
+
306
+ /**
307
+ * A gate definition.
308
+ *
309
+ * @throws {AldusError} `ALDUS_GATE_NOT_FOUND`
310
+ */
311
+ require(gateId: string): ResolvedGateDefinition {
312
+ const gate = this.#gates.get(gateId);
313
+ if (gate === undefined) {
314
+ throw gateEngineError(
315
+ GateEngineErrorCodes.GATE_NOT_FOUND,
316
+ `Gate "${gateId}" is not registered.`,
317
+ { category: "not_found", details: { gateId } },
318
+ );
319
+ }
320
+ return gate;
321
+ }
322
+
323
+ /** Gates that directly depend on `gateId`. */
324
+ dependentsOf(gateId: string): ResolvedGateDefinition[] {
325
+ return this.list().filter((gate) => gate.dependsOn.includes(gateId));
326
+ }
327
+
328
+ /**
329
+ * Gates that transitively depend on `gateId`, nearest first, excluding `gateId` itself.
330
+ *
331
+ * This is the reach of contract §13.1's cascade: invalidating a Content Freeze invalidates
332
+ * every approval downstream of it.
333
+ */
334
+ downstreamOf(gateId: string): string[] {
335
+ const seen = new Set<string>();
336
+ const ordered: string[] = [];
337
+ let frontier = [gateId];
338
+ while (frontier.length > 0) {
339
+ const next: string[] = [];
340
+ for (const current of frontier) {
341
+ for (const dependent of this.dependentsOf(current)) {
342
+ if (seen.has(dependent.gateId)) continue;
343
+ seen.add(dependent.gateId);
344
+ ordered.push(dependent.gateId);
345
+ next.push(dependent.gateId);
346
+ }
347
+ }
348
+ frontier = next;
349
+ }
350
+ return ordered;
351
+ }
352
+ }
353
+
354
+ /** Depth-first cycle search, returning the offending path if there is one. */
355
+ function findCycle(gates: ReadonlyMap<string, ResolvedGateDefinition>): string[] | undefined {
356
+ const visiting = new Set<string>();
357
+ const done = new Set<string>();
358
+ const path: string[] = [];
359
+
360
+ const walk = (gateId: string): string[] | undefined => {
361
+ if (done.has(gateId)) return undefined;
362
+ if (visiting.has(gateId)) return [...path.slice(path.indexOf(gateId)), gateId];
363
+ visiting.add(gateId);
364
+ path.push(gateId);
365
+ for (const dependency of gates.get(gateId)?.dependsOn ?? []) {
366
+ const found = walk(dependency);
367
+ if (found !== undefined) return found;
368
+ }
369
+ path.pop();
370
+ visiting.delete(gateId);
371
+ done.add(gateId);
372
+ return undefined;
373
+ };
374
+
375
+ for (const gateId of gates.keys()) {
376
+ const found = walk(gateId);
377
+ if (found !== undefined) return found;
378
+ }
379
+ return undefined;
380
+ }