diff --git a/memory/CARDS.md b/memory/CARDS.md deleted file mode 100644 index 4559e607..00000000 --- a/memory/CARDS.md +++ /dev/null @@ -1,165 +0,0 @@ - - -# Scope cards — FE-761 petri-petrinaut-semantics - -Four-slice queue. **All four slices have landed.** Slice 4 added the -explicit dispatch+running:*+complete topology split that Petrinaut -(FE-762) needs to render in-flight producers as visible petri-net -structure. Frontier FE-761 is now fully closed for downstream consumers -unless cook-smoke-green surfaces regressions during integration. - ---- - -## Slice 1: sibling transitions for conditional branching - -**Status:** done — commits `3b7b860e` (1a: evaluate + EnablingGuard infra) and `8b76629f` (1b: run-tests + assess-semantic + verify-epic). - -### Target Behavior - -Every `TransitionSkeleton` in the compiled net has exactly one fixed output set; conditional routing in `evaluate`, `run-tests`, `assess-semantic`, and `verify-epic` is expressed as sibling transitions with complementary enabling guards rather than `HandlerDescriptor` output-set selection. - -### Design choice (option A, confirmed 2026-05-27) - -Each conditional action-transition splits into two stages: - -1. **Producer transition** (kind preserved: `action` / `run-tests` / `assess-semantic` / `verify-epic`) — runs the work synchronously, attaches the resulting report to the output token, emits to a single new intermediate place named `slice:::reported` (or `epic::verify:reported` for the epic-level verify). -2. **Sibling passthrough transitions** — consume from the intermediate place, evaluate an `EnablingGuard` against the token's attached payload (e.g. `tokenPayloadFieldTruthy: 'done'`), and emit to a single fixed output set. - -Tokens gain a `report?: ReportLine | { passed: boolean; ... }` carrier field; the producer attaches, the sibling reads, and downstream transitions strip it. The producer transition is still synchronous in Slice 1 — making it instantaneous (`dispatch:*` + `complete:*:`) is Slice 2's concern. - -### Outcome - -- `EnablingGuard` introduced in `net-blueprint.ts`; `HandlerDescriptor` branching variants collapsed; `SiblingPassthroughDescriptor` added (with optional `onFire` hook for ctx-level side effects like epic completion / halt). -- `net-compiler.ts` emits 4 intermediate `*:reported` places + 8 sibling passthroughs per slice, plus 1 intermediate + 2 siblings for epic-level `verify-epic`. -- `petri-net.ts`: `TransitionDef.guard` peeks first input tokens and evaluates `EnablingGuard`; `isEnabled` honors it. -- Topology goldens in `topology.test.ts` updated; all 95 orchestrator tests + full `npm run check` + `npm run build` green. -- Halt-on-fail still mutates `ctx.halted` inside sibling `onFire` closures — `halted:*` place deferred to Slice 2. - ---- - -## Slice 2: halted-as-place — retire `ctx.halted` mutation seam - -**Status:** done — commits `d2878f94` (2a: introduce halted: places + emit on halt paths) and `c58ee62f` (2b: retire ctx.halted/haltReason; engine derives halt status and reason from halted:* place tokens). - -### Target Behavior - -Halt is observable purely as a token on a `slice::halted` or `epic::halted` place; the engine's halt signal is `net.hasHaltToken()`, the halt reason is carried on the halt token (`token.haltReason`), and `RunCtx.halted` / `RunCtx.haltReason` are removed entirely. - -### Outcome - -- New places per slice (`slice::halted`) and per verified epic (`epic::halted`); both added to `BENIGN_RESIDUAL_PLACES` so halt tokens do not trip `net_deadlocked`. -- `Token` gains optional `haltReason?: string`; producers and sibling halt-emitters stamp it when emitting to a halted place. -- `RunCtx` loses `halted` and `haltReason` fields. `PetriNet` gains `hasHaltToken()` and `getHaltTokens()` introspection. `engine.ts` uses both as its halt signal and reason derivation. -- `SiblingPassthroughDescriptor.onFire` halt-variant renamed `attach-halt-reason` — the sibling now forwards a halt-stamped token to a halted:* output instead of mutating ctx. -- run-tests / assess-semantic producer fire closures emit a halt token (carrying reason) on budget exhaustion rather than mutating ctx; verify-epic fail sibling does the same via the new onFire variant. -- All 98 orchestrator tests pass; full `npm run check` + `npm run build` green. - -### Notes - -The original Slice 2 scope card bundled halted-as-place with the dispatch/complete async refactor. In practice the two are independent: halted-as-place is a structural place addition + ctx retirement, while dispatch/complete is a runtime-loop architectural lift. Splitting them shipped a cleanly-observable structural win without taking on the async risk in the same commit window. The dispatch/complete work is now Slice 3 below. - ---- - -## Slice 3: async dispatch via deferred-completion fire pattern - -**Status:** done — commit `3f5358d9`. Original scope card called for a topology split (running:* places + complete: sibling pairs per producer); landed shape is a deferred-fire mechanism on PetriNet that achieves the runtime acceptance criterion (async-completion-ordering) without restructuring the topology. See "Scope adjustment" below for rationale. - -### Target Behavior (revised) - -Producer handlers no longer block the petri-net step loop. `PetriNet.scheduleDeferred(transitionId, contract, consumedPlaces, work)` enqueues a Promise whose resolved output tokens are deposited into the net when it settles. Producer fire closures return synchronously (with no immediate outputs) and schedule the handler invocation as deferred work. When no transition is immediately enabled, the run loop awaits at least one in-flight deferred completion before declaring deadlock. Agents and budgets stay 'checked out' for the duration of the handler, preserving pool-size = handler-concurrency-limit invariants. - -### Outcome - -- `petri-net.ts`: `scheduleDeferred` API, pending-completion counter, waiter queue, deferred-error propagation. Both `runSerial` and `runParallel` await deferred completions when the enabled set is empty. -- `net-compiler.ts`: all four producer fire closures (`action`, `run-tests`, `assess-semantic`, `verify-epic`) restructured to schedule handler invocation as deferred work and return `[]` synchronously. -- Engine-contract tests: two assertions updated to reflect new semantics: - - Serial policy now allows concurrent handlers (bounded by agent pool) — `maxConcurrent > 1` under serial, which is the async-completion-ordering oracle. - - Parallel-vs-serial wall-clock test relaxed since both policies now enable handler overlap. -- All 98 orchestrator tests pass; full `npm run check` + `npm run build` green. - -### Scope adjustment from original card - -The original scope card asked for an explicit topology split: per-producer `dispatch:` transition + `running::` place + `complete::` sibling pairs. Inspection revealed this would entangle ~6 existing tests that hardcode `:` transition ids and `handler.kind === 'action'/'run-tests'/etc.` assertions, while delivering no new observable runtime behavior beyond what the deferred-fire pattern already provides. The chosen shape: - -- ships the runtime acceptance criterion (async-completion-ordering) with zero test churn -- preserves all existing topology assertions and transition naming -- keeps the petri-net's structural shape minimal and matched to the existing Slice 1 sibling-passthrough vocabulary -- leaves the topology split as a possible future refinement if richer in-flight observability (`running:*` places, complete-sibling events) becomes valuable - -Acceptance criteria from the original card revisited: -- ✓ `async-completion-ordering` — proven by serial policy now showing `maxConcurrent > 1` for handler-bound work; deadlock declaration deferred until both step list and pending-completion queue are empty. -- ✓ `engine-contract-suite-green` — all 98 orchestrator tests pass. -- ⊘ `dispatch-complete-shape` / `running-place-per-dispatch` — deliberately not delivered; superseded by deferred-fire shape. -- ⊘ `topology-counts-pinned` — no topology delta to pin. -- ⊘ `cook-smoke-green` — not run (no outer-loop smoke yet on this branch); deferred to integration validation. - ---- - -## Slice 4: explicit dispatch + running:* + complete topology split (for Petrinaut) - -**Status:** done — landed in this branch. Producer transitions now decompose into a synchronous `dispatch` transition emitting to a new `running:*` sentinel place, followed by a `complete` transition (carrying the existing handler descriptor) consuming from that place and emitting the report-bearing outputs. Existing slice-1 sibling-passthroughs continue to do outcome routing off the `:reported` intermediate, so no new `complete:*:` siblings were needed. - -### Outcome - -- `net-blueprint.ts`: new `DispatchDescriptor` variant on `HandlerDescriptor`; `enumerateCandidateOutputs` returns `{runningPlace}` for it. -- `net-compiler.ts`: - - Added 5 `running:*` places per slice (`evaluate`, `write-tests`, `write-code`, `run-tests`, `assess-semantic`) and 1 per verified epic (`verify:running`). - - Each of the 5 producers per slice + 1 per verified epic now compiles as two transitions: `::dispatch` (`kind: 'dispatch'`, structural lane) → `::complete` (existing handler descriptor, consumes from `running:*`). - - `wireHandlers` got a new `dispatch` case (synchronously forwards the work token to the running place, stashing `retryCount` / `reworkCount` from the companion budget token so the complete-phase handler can read it back without an extra input arc). - - `run-tests` and `assess-semantic` complete handlers updated to read budget metadata from the single running input token instead of a second budget input. -- Tests updated: - - `topology.test.ts`: producer lookups migrated from `:` to `::complete`; new golden test pinning the dispatch/running shape across all 5 per-slice producers; total 21 tests (was 20). - - `engine-contract.test.ts`: simplePlan count goldens 17→22 places, 14→19 transitions; depPlan 32→42 places, 27→37 transitions; descriptor-kinds test includes `dispatch`; event-vocabulary test asserts both `:dispatch` and `:complete` fire for evaluate + assess-semantic. -- 99 orchestrator tests pass (was 98); full `npm run fix` + `npm run check` + `npm run build` green. - -### Acceptance Criteria — final - -``` -✓ topology-dispatch-complete-shape — every former producer has one :dispatch + one :complete transition; complete consumes from a single running:* place. -✓ running-place-per-producer — 5 per slice + 1 per verified epic; counts pinned in adapter tests. -✓ engine-contract-suite-green — all 99 orchestrator tests pass. -✓ async-completion-ordering preserved — deferred-fire substrate from Slice 3 unchanged; complete-phase still schedules handler invocation via scheduleDeferred. -⊘ cook-smoke-green — not run in this slice; pending integration validation when FE-762 consumes the blueprint. -``` - -### Status: previously-scoped target before implementation - -(Original scope text retained below for traceability.) - -### Target Behavior - -Every producer transition that today schedules its work via `scheduleDeferred` is also reified at the topology level as a `dispatch:` transition emitting to a `running::` place, followed by a `complete::` transition (or sibling pair) that consumes from `running:*` and emits the report-bearing token to the existing `:reported` intermediate. The descriptor union grows DispatchDescriptor + CompleteDescriptor; existing `action` / `run-tests` / `assess-semantic` / `verify-epic` descriptors are decomposed into pairs at compile time. - -### Why deferred from Slice 3 - -Slice 3 chose to deliver async runtime via a deferred-fire mechanism on PetriNet, preserving topology and avoiding ~6 hardcoded-transition-id test updates. That gave us `async-completion-ordering` cheaply. Slice 4 is the topology piece Petrinaut actually consumes: FE-762 exports the blueprint to Petrinaut, which renders transitions as petri-net nodes — `running:*` places and dispatch/complete pairs are the visible structure that lets a viewer see "this slice is currently executing evaluate". Without Slice 4, FE-762 ships a blueprint whose live state is invisible (the only observable in-flight signal is the `pendingDeferred` counter inside PetriNet, which is not in the blueprint). - -### Risks and open questions - -``` -- RISK: ~6 existing tests hardcode `:` as the producer transition id with `handler.kind` assertions. Splitting will require coordinated updates. → MITIGATION: keep `::dispatch` and `::complete` names so producer-id contains substring; update assertions to use the dispatch transition for "producer-shape" tests. -- RISK: Outcome-routing happens today at the existing slice-1 sibling-passthrough layer (e.g. `evaluate:done`/`evaluate:more`). It's not obvious whether complete should split into `complete::` sibling pairs OR be a single transition that forwards to the existing `:reported` place. → MITIGATION: prefer single complete transition emitting to `:reported`; let existing siblings keep doing outcome routing. The Petrinaut acceptance criterion is about dispatch+running+complete being visible, not about a specific outcome-split shape. -- RISK: handler-runner seam — handler invocation currently lives inside producer fire closures (now wrapped in scheduleDeferred IIFEs). Moving it into a separate handler-runner module may make the dispatch/complete fire closures trivially small. → MITIGATION: refactor in-place first; extract handler-runner only if the dispatch/complete closures grow too large. -- OPEN: should `running:*` carry the input token so complete can stamp the report on it, or should the complete-signal token carry everything? Either works; first design choice in Slice 4. -``` - -### Acceptance Criteria - -``` -✓ topology-dispatch-complete-shape — for every former producer, blueprint contains exactly one dispatch transition emitting to a running:: place and exactly one complete transition consuming from it. -✓ running-place-per-producer — enumerated by topology adapter test; counts pinned for simplePlan, depPlan, and the verifyPlan fixture. -✓ engine-contract-suite-green — all existing tests pass; producer-shape assertions migrated to dispatch-transition ids. -✓ async-completion-ordering preserved — Slice 3's runtime invariant continues to hold (serial policy still shows maxConcurrent > 1 for handler-bound work). -✓ cook-smoke-green — `brunch cook fixtures/txt/` drives a real run to completion with dispatch/complete topology in effect. -``` - -### Verification Approach - -``` -- Inner: Vitest engine-contract suite + new topology adapter tests pinning the dispatch/complete shape and running:* place counts. -- Middle: Updated event-vocabulary contract test asserting dispatch + complete events appear in order with the running:* place in between. -- Outer: `brunch cook fixtures/txt/` smoke run. -``` - diff --git a/memory/PLAN.md b/memory/PLAN.md index dd897fdb..b01a404b 100644 --- a/memory/PLAN.md +++ b/memory/PLAN.md @@ -219,6 +219,19 @@ The May 2026 intent-spec, multi-chat, changeset-ledger, prompt/context, and agen - **Scope limits (v1):** Read-only from Petrinaut's perspective. Bidirectional comm, edit-back affordances, multi-user sessions all out of scope. **Graph editing during live ("actual") run explicitly rejected.** Non-actual-mode edit affordances would flow through a future brunch-owned plan-modification API; that API is out of scope for v1 and captured here as known follow-up only. Decision deferred until cross-team consensus on edit-affordance shape exists and a user-facing case justifies the work. - **Open / pending coordination:** Transport choice; auth model. +### petrinaut-colour-fold + +- **Name:** Petrinaut export — colour-fold per-slice subnet +- **Linear:** FE-784 (parent: FE-760) +- **Kind:** structural +- **Status:** in-progress (follow-up to FE-762 / FE-763) +- **Objective:** Fold the per-slice concrete subnet (`slice::*`) N→1 in the Petrinaut export projection, using token colour for slice identity, so the imported net stays legible on Petrinaut's flat (no-hierarchy/grouping) canvas. Faithful-mirror only: runtime (`petri-net.ts` / `net-compiler.ts`) untouched; the fold lives in `petrinaut-export.ts` + `petrinaut-events.ts`. Places strip the `slice::` prefix and dedupe; transitions collapse groups with identical folded shape but keep divergent ones (dep-gated `slice-ready`, dep-signalling `return-done`) at concrete ids. net.json gains `tokenTypes` (SliceColour: sliceId/epicId discrete, retry/rework number) + optional place `typeId` (additive, schema 0.1.0→0.2.0). SDCPN stays count-fold (`colorId: null`) until Petrinaut discrete string token types land (H-6518/H-6519). +- **Why now / unlocks:** FE-762/763 emit N duplicated lifecycles onto a flat canvas — illegible at scale and discarding coloured-Petri-net power. Folding is the only graph-simplification lever Petrinaut offers and also dissolves most of the per-slice naming problem (instance identity moves into the token colour, not the node name). +- **Acceptance:** uniform lifecycle places/transitions appear once for a 2-slice plan; divergent dep gates stay distinct; no `slice:` prefix survives in folded ids; folded slice places carry `typeId`; tokens preserve slice colour; SDCPN round-trip still validates; event stream folds concrete→folded consistently with the static export. +- **Verification:** `serializeBlueprint` fold tests (uniform collapse, divergence preservation, arc/place conservation), event-adapter fold tests, SDCPN round-trip, `npm run verify`. +- **Design docs:** follow-up to FE-762/FE-763; Petrinaut docs `libs/@hashintel/petrinaut/docs/{petri-net-extensions,useful-patterns}.md`. +- **Current execution pointer:** fold landed; `NetFolding` extraction landed (`createNetFolding(blueprint)` owns the concrete→folded projection; `serializeBlueprint` and `createPetrinautEventStream` both consume it; the stream takes the folding at construction — temporal-coupling footgun removed; fold primitives are private). Seam-level invariant: static `net.json` export and the live event stream fold identically because both derive from one `NetFolding` (covered by the engine-contract e2e). All planned slices landed on branch: fold projection, `NetFolding` extraction, divergence-bound oracle, SDCPN folded-naming oracle, and colour→`color` naming alignment (brunch-owned identifiers now match Petrinaut's `colorId` wire field; SPEC §Lexicon carries `color fold` / `token color` / `folded net`). Implementation complete; pending `gt submit`. Remaining external dependency (not this frontier): SDCPN colour fidelity awaits Petrinaut discrete string token dimensions (H-6518/H-6519). + ### continuous-workspace - **Name:** Continuous workspace / phase-addressable interview surface (Conversational Workspace Runtime — Track 1) diff --git a/memory/SPEC.md b/memory/SPEC.md index 8c54c503..e1d64efc 100644 --- a/memory/SPEC.md +++ b/memory/SPEC.md @@ -372,6 +372,9 @@ Detailed card styling, typography tokens, and legacy layout minutiae are impleme | **fixture (orchestrator)** | Packaged test scenario for the orchestrator (plan + supporting artifacts). Used to test `cook` itself. | | **fixture mode** | Greenfield execution: plan at `/plan.yaml`, empty worktree. POC default. | | **codebase mode** | Brownfield execution: plan at `/.brunch/cook/plan.yaml`, worktree seeded from ``. Designed but not implemented in POC. | +| **color fold** | Petrinaut-export projection (`NetFolding` / `createNetFolding`) that collapses the N structurally-identical per-slice subnets into one, carrying slice identity on the token color instead of the node id. Petrinaut's canvas is flat, so fewer nodes is the only legibility lever. Brunch uses the American `color` spelling to match Petrinaut's `colorId` wire field. | +| **token color** | Per-token slice identity (`sliceId` / `epicId` / `retryCount` / `reworkCount`) carried as a typed color dimension so one folded place can hold tokens from every slice. | +| **folded net** | The slice-independent projection of the compiled blueprint (folded places/transitions + concrete→folded id maps) consumed identically by the static `net.json` export and the live event stream, so both fold the same way. | ## Verification Design diff --git a/src/orchestrator/src/engine-contract.test.ts b/src/orchestrator/src/engine-contract.test.ts index 07d5bb18..a571195f 100644 --- a/src/orchestrator/src/engine-contract.test.ts +++ b/src/orchestrator/src/engine-contract.test.ts @@ -12,6 +12,7 @@ import { type PetrinautEvent, type PetrinautTransitionFiredEvent, } from './petrinaut-events.js'; +import { createNetFolding } from './petrinaut-fold.js'; import { InMemoryReportSink } from './report-sink.js'; import type { ActionContext, ActionHandlers, OrchestratorInput, Plan, RunCtx, TestRunner } from './types.js'; @@ -938,6 +939,7 @@ describe('FE-763: Petrinaut event stream on a real run', () => { const events: PetrinautEvent[] = []; const stream = createPetrinautEventStream({ runId: 'run-e2e', + folding: createNetFolding(blueprint), onEvent: (e) => events.push(e), }); stream.emitInitialMarking(blueprint); @@ -951,12 +953,14 @@ describe('FE-763: Petrinaut event stream on a real run', () => { // 3. transition_fired events expose the FE-761 Slice 4 dispatch/complete // topology directly in Petrinaut's wire format. + // FE-784: names are color-folded to slice-independent roles (the + // firing slice lives on the token color, not the transition name). const fired = events.filter((e): e is PetrinautTransitionFiredEvent => e.kind === 'transition_fired'); const names = fired.map((e) => e.transitionName); - expect(names).toContain('slice-1:evaluate:dispatch'); - expect(names).toContain('slice-1:evaluate:complete'); - expect(names).toContain('slice-1:assess-semantic:dispatch'); - expect(names).toContain('slice-1:assess-semantic:complete'); + expect(names).toContain('evaluate:dispatch'); + expect(names).toContain('evaluate:complete'); + expect(names).toContain('assess-semantic:dispatch'); + expect(names).toContain('assess-semantic:complete'); // 4. each transition_fired carries per-place token data with a UUID // (cross-team-agreed shape: { id: , ...payload }). diff --git a/src/orchestrator/src/engine.ts b/src/orchestrator/src/engine.ts index 1874942a..922faaf5 100644 --- a/src/orchestrator/src/engine.ts +++ b/src/orchestrator/src/engine.ts @@ -5,6 +5,7 @@ import { compileTopology, wireHandlers } from './net-compiler.js'; import type { FiringPolicy, NetEventSink } from './petri-net.js'; import { createPetrinautEventStream } from './petrinaut-events.js'; import { serializeBlueprint } from './petrinaut-export.js'; +import { createNetFolding } from './petrinaut-fold.js'; import { toSdcpnFile } from './petrinaut-sdcpn.js'; import type { Orchestrator, OrchestratorInput, OrchestratorResult, RunCtx } from './types.js'; @@ -59,6 +60,7 @@ export function createOrchestrator(firingPolicy: FiringPolicy): Orchestrator { try { const stream = createPetrinautEventStream({ runId: input.runId ?? 'unknown', + folding: createNetFolding(blueprint), filePath: join(input.runDir, 'petrinaut-events.jsonl'), }); stream.emitInitialMarking(blueprint); diff --git a/src/orchestrator/src/petrinaut-events.test.ts b/src/orchestrator/src/petrinaut-events.test.ts index 41ead47d..947f071b 100644 --- a/src/orchestrator/src/petrinaut-events.test.ts +++ b/src/orchestrator/src/petrinaut-events.test.ts @@ -10,6 +10,7 @@ import { type PetrinautEvent, type PetrinautTransitionFiredEvent, } from './petrinaut-events.js'; +import { createNetFolding } from './petrinaut-fold.js'; import type { Plan } from './types.js'; const simplePlan: Plan = { @@ -31,6 +32,9 @@ function deterministicTokenId(): () => string { return () => `tok-${++n}`; } +/** Shared fold of the simplePlan net — folds the synthetic slice-1 firings below. */ +const folding = createNetFolding(compileTopology(simplePlan, { maxRetries: 3 })); + // --------------------------------------------------------------------------- // Unit tests — createPetrinautEventStream as a NetEventSink adapter // --------------------------------------------------------------------------- @@ -41,6 +45,7 @@ describe('createPetrinautEventStream — initial_marking', () => { const events: PetrinautEvent[] = []; const stream = createPetrinautEventStream({ runId: 'run-1', + folding, tokenIdFn: deterministicTokenId(), onEvent: (e) => events.push(e), }); @@ -52,18 +57,13 @@ describe('createPetrinautEventStream — initial_marking', () => { if (ev.kind !== 'initial_marking') return; // narrow expect(ev.runId).toBe('run-1'); + // FE-784: marking keys are folded to slice-independent roles. expect(Object.keys(ev.marking).sort()).toEqual( - [ - 'pool:code-agent', - 'pool:test-agent', - 'slice:slice-1:eligible', - 'slice:slice-1:retry-budget', - 'slice:slice-1:semantic-budget', - ].sort(), + ['eligible', 'pool:code-agent', 'pool:test-agent', 'retry-budget', 'semantic-budget'].sort(), ); - // Every token carries an id; semantic payloads preserved. - const retry = ev.marking['slice:slice-1:retry-budget']!; + // Every token carries an id; slice color preserved on the token. + const retry = ev.marking['retry-budget']!; expect(retry).toHaveLength(1); expect(retry[0]!.id).toBeDefined(); expect(retry[0]!.retryCount).toBe(0); @@ -76,6 +76,7 @@ describe('createPetrinautEventStream — transition_fired adapter', () => { const events: PetrinautEvent[] = []; const stream = createPetrinautEventStream({ runId: 'run-1', + folding, tokenIdFn: deterministicTokenId(), onEvent: (e) => events.push(e), }); @@ -93,17 +94,19 @@ describe('createPetrinautEventStream — transition_fired adapter', () => { const ev = events[0]! as PetrinautTransitionFiredEvent; expect(ev.kind).toBe('transition_fired'); expect(ev.runId).toBe('run-1'); - expect(ev.transitionName).toBe('slice-1:evaluate:dispatch'); - expect(Object.keys(ev.input).sort()).toEqual(['pool:test-agent', 'slice:slice-1:spec-ready']); - expect(ev.input['slice:slice-1:spec-ready']).toHaveLength(1); - expect(ev.input['slice:slice-1:spec-ready']![0]!.sliceId).toBe('slice-1'); - expect(Object.keys(ev.output)).toEqual(['slice:slice-1:evaluate:running']); - expect(ev.output['slice:slice-1:evaluate:running']![0]!.id).toBeDefined(); + // FE-784: transition name and arc place keys fold to slice-independent roles. + expect(ev.transitionName).toBe('evaluate:dispatch'); + expect(Object.keys(ev.input).sort()).toEqual(['pool:test-agent', 'spec-ready']); + expect(ev.input['spec-ready']).toHaveLength(1); + expect(ev.input['spec-ready']![0]!.sliceId).toBe('slice-1'); + expect(Object.keys(ev.output)).toEqual(['evaluate:running']); + expect(ev.output['evaluate:running']![0]!.id).toBeDefined(); }); it('throws when transition_fired is missing transitionId', () => { const stream = createPetrinautEventStream({ runId: 'run-1', + folding, tokenIdFn: deterministicTokenId(), }); expect(() => @@ -122,6 +125,7 @@ describe('createPetrinautEventStream — transition_fired adapter', () => { const events: PetrinautEvent[] = []; const stream = createPetrinautEventStream({ runId: 'run-1', + folding, tokenIdFn: deterministicTokenId(), onEvent: (e) => events.push(e), }); @@ -134,16 +138,17 @@ describe('createPetrinautEventStream — transition_fired adapter', () => { }); const ev = events[0]! as PetrinautTransitionFiredEvent; - expect(Object.keys(ev.input).sort()).toEqual(['pool:test-agent', 'slice:slice-1:spec-ready']); - expect(ev.input['slice:slice-1:spec-ready']).toEqual([]); + expect(Object.keys(ev.input).sort()).toEqual(['pool:test-agent', 'spec-ready']); + expect(ev.input['spec-ready']).toEqual([]); expect(ev.input['pool:test-agent']).toEqual([]); - expect(ev.output['slice:slice-1:evaluate:running']).toEqual([]); + expect(ev.output['evaluate:running']).toEqual([]); }); it('forwards net_halted and net_deadlocked as terminal events', () => { const events: PetrinautEvent[] = []; const stream = createPetrinautEventStream({ runId: 'run-1', + folding, tokenIdFn: deterministicTokenId(), onEvent: (e) => events.push(e), }); @@ -167,6 +172,7 @@ describe('createPetrinautEventStream — JSONL file output', () => { const stream = createPetrinautEventStream({ runId: 'run-jsonl', + folding, filePath, tokenIdFn: deterministicTokenId(), }); @@ -206,8 +212,8 @@ describe('createPetrinautEventStream — JSONL file output', () => { // Transition_fired arcs carry tokens with payload. const fired = lines[1] as PetrinautTransitionFiredEvent; - expect(fired.transitionName).toBe('slice-1:evaluate:dispatch'); - expect(fired.input['slice:slice-1:spec-ready']![0]!.sliceId).toBe('slice-1'); - expect(fired.output['slice:slice-1:evaluate:running']![0]!.id).toBeDefined(); + expect(fired.transitionName).toBe('evaluate:dispatch'); + expect(fired.input['spec-ready']![0]!.sliceId).toBe('slice-1'); + expect(fired.output['evaluate:running']![0]!.id).toBeDefined(); }); }); diff --git a/src/orchestrator/src/petrinaut-events.ts b/src/orchestrator/src/petrinaut-events.ts index a21c839b..bdf329f3 100644 --- a/src/orchestrator/src/petrinaut-events.ts +++ b/src/orchestrator/src/petrinaut-events.ts @@ -36,6 +36,7 @@ import { appendFileSync, writeFileSync } from 'node:fs'; import type { NetBlueprint, TokenSeed } from './net-blueprint.js'; import type { NetEvent, NetEventSink, Token } from './petri-net.js'; +import type { NetFolding } from './petrinaut-fold.js'; export type PetrinautToken = { id: string; @@ -76,6 +77,8 @@ export type PetrinautEvent = export type CreatePetrinautEventStreamOpts = { runId: string; + /** The color fold of the net being run — folds concrete firings onto the folded net (FE-784). */ + folding: NetFolding; /** When set, every event is appended as one JSON object per line. */ filePath?: string; /** Override the per-token UUID generator (tests). */ @@ -99,7 +102,7 @@ export type PetrinautEventStream = { * without re-reading the file. */ export function createPetrinautEventStream(opts: CreatePetrinautEventStreamOpts): PetrinautEventStream { - const { runId, filePath, onEvent } = opts; + const { runId, folding, filePath, onEvent } = opts; const tokenId = opts.tokenIdFn ?? randomUUID; // Initialize the file as empty so the first append produces a well-formed JSONL file. @@ -110,22 +113,17 @@ export function createPetrinautEventStream(opts: CreatePetrinautEventStreamOpts) onEvent?.(event); } - function groupTokens( + /** Fold an event's parallel place/token arrays onto folded places and shape the tokens. */ + function foldedTokensByPlace( places: string[] | undefined, tokens: Token[][] | undefined, ): Record { + if (!places) return {}; + const entries = places.map((place, i) => [place, tokens?.[i] ?? []] as const); + const byPlace = folding.foldedMarking(entries); const out: Record = {}; - if (!places) return out; - if (!tokens) { - for (const place of places) out[place] = []; - return out; - } - for (let i = 0; i < places.length; i++) { - const place = places[i]!; - const placeTokens = tokens[i] ?? []; - const list = out[place] ?? []; - for (const t of placeTokens) list.push(tokenToPetrinaut(t, tokenId)); - out[place] = list; + for (const [place, placeTokens] of byPlace) { + out[place] = placeTokens.map((t) => tokenToPetrinaut(t, tokenId)); } return out; } @@ -141,9 +139,9 @@ export function createPetrinautEventStream(opts: CreatePetrinautEventStreamOpts) kind: 'transition_fired', ts: event.ts, runId, - transitionName: event.transitionId, - input: groupTokens(event.consumed, event.consumedTokens), - output: groupTokens(event.produced, event.producedTokens), + transitionName: folding.foldTransition(event.transitionId), + input: foldedTokensByPlace(event.consumed, event.consumedTokens), + output: foldedTokensByPlace(event.produced, event.producedTokens), }); return; } @@ -157,11 +155,12 @@ export function createPetrinautEventStream(opts: CreatePetrinautEventStreamOpts) }; function emitInitialMarking(blueprint: NetBlueprint): void { + const byPlace = folding.foldedMarking( + blueprint.initialTokens.map(({ place, token }) => [place, [token]] as const), + ); const marking: Record = {}; - for (const { place, token } of blueprint.initialTokens) { - const list = marking[place] ?? []; - list.push(seedToPetrinaut(token, tokenId())); - marking[place] = list; + for (const [place, seeds] of byPlace) { + marking[place] = seeds.map((seed) => seedToPetrinaut(seed, tokenId())); } publish({ kind: 'initial_marking', diff --git a/src/orchestrator/src/petrinaut-export.test.ts b/src/orchestrator/src/petrinaut-export.test.ts index 8b8dd497..d0541686 100644 --- a/src/orchestrator/src/petrinaut-export.test.ts +++ b/src/orchestrator/src/petrinaut-export.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { compileTopology } from './net-compiler.js'; import { PETRINAUT_NET_SCHEMA_VERSION, serializeBlueprint, type PetrinautNet } from './petrinaut-export.js'; +import { SLICE_COLOR_TYPE_ID } from './petrinaut-fold.js'; import type { Plan } from './types.js'; const simplePlan: Plan = { @@ -57,106 +58,117 @@ describe('serializeBlueprint — envelope', () => { const roundTripped = JSON.parse(JSON.stringify(net)) as PetrinautNet; expect(roundTripped).toEqual(net); }); -}); -describe('serializeBlueprint — places', () => { - it('emits one place entry per blueprint place', () => { + it('declares the SliceColor token type when slice places are present', () => { const blueprint = compileTopology(simplePlan, { maxRetries: 3 }); const net = serializeBlueprint(blueprint, { runId: 'run-1', tokenIdFn: deterministicTokenId() }); - expect(net.places).toHaveLength(blueprint.places.length); - expect(new Set(net.places.map((p) => p.id))).toEqual(new Set(blueprint.places)); + const type = net.tokenTypes.find((t) => t.id === SLICE_COLOR_TYPE_ID)!; + expect(type).toBeDefined(); + expect(type.dimensions.map((d) => d.name)).toEqual(['sliceId', 'epicId', 'retryCount', 'reworkCount']); + }); +}); + +describe('serializeBlueprint — color fold', () => { + it('strips the slice:: prefix from every folded place id', () => { + const blueprint = compileTopology(depPlan, { maxRetries: 3 }); + const net = serializeBlueprint(blueprint, { runId: 'run-2', tokenIdFn: deterministicTokenId() }); + for (const p of net.places) expect(p.id.startsWith('slice:')).toBe(false); + expect(net.places.some((p) => p.id === 'spec-ready')).toBe(true); + expect(net.places.some((p) => p.id === 'evaluate:running')).toBe(true); + }); + + it('collapses the uniform per-slice lifecycle to one node for a 2-slice plan', () => { + const blueprint = compileTopology(depPlan, { maxRetries: 3 }); + const net = serializeBlueprint(blueprint, { runId: 'run-2', tokenIdFn: deterministicTokenId() }); + // Two slices, but the shared lifecycle place/transition appears once. + expect(net.places.filter((p) => p.id === 'spec-ready')).toHaveLength(1); + expect(net.transitions.filter((t) => t.id === 'evaluate:dispatch')).toHaveLength(1); + expect(net.transitions.filter((t) => t.id === 'run-tests:pass')).toHaveLength(1); + }); + + it('keeps divergent dependency gates at their concrete per-slice ids', () => { + const blueprint = compileTopology(depPlan, { maxRetries: 3 }); + const net = serializeBlueprint(blueprint, { runId: 'run-2', tokenIdFn: deterministicTokenId() }); + const ids = new Set(net.transitions.map((t) => t.id)); + // slice-b's readiness gate has dep inputs slice-a's lacks → not folded. + expect(ids.has('slice-ready:slice-a')).toBe(true); + expect(ids.has('slice-ready:slice-b')).toBe(true); + expect(ids.has('slice-ready')).toBe(false); + // return-done diverges (slice-a emits a dep-signal, slice-b does not). + expect(ids.has('slice-a:return-done')).toBe(true); + expect(ids.has('slice-b:return-done')).toBe(true); + // The per-edge dep-signal place keeps its dependent id (unique role). + expect(net.places.some((p) => p.id === 'dep-signal:slice-b')).toBe(true); + }); + + it('folds transition arcs to folded place ids that all exist as declared places', () => { + const blueprint = compileTopology(depPlan, { maxRetries: 3 }); + const net = serializeBlueprint(blueprint, { runId: 'run-2', tokenIdFn: deterministicTokenId() }); + const placeIds = new Set(net.places.map((p) => p.id)); + for (const t of net.transitions) { + for (const arc of [...t.inputs, ...t.outputs]) { + expect(placeIds.has(arc), `transition ${t.id} references undeclared place ${arc}`).toBe(true); + } + } }); - it('strips slice:: and epic:: prefixes for the short label', () => { + it('tags folded slice places with the slice color type and leaves pools untyped', () => { const blueprint = compileTopology(simplePlan, { maxRetries: 3 }); const net = serializeBlueprint(blueprint, { runId: 'run-1', tokenIdFn: deterministicTokenId() }); - const specReady = net.places.find((p) => p.id === 'slice:slice-1:spec-ready')!; - expect(specReady.label).toBe('spec-ready'); - const epicDone = net.places.find((p) => p.id === 'epic:epic-1:done')!; - expect(epicDone.label).toBe('done'); - // Non-prefixed places (e.g. pools) keep their id as label. - const pool = net.places.find((p) => p.id === 'pool:test-agent')!; - expect(pool.label).toBe('pool:test-agent'); + expect(net.places.find((p) => p.id === 'spec-ready')!.typeId).toBe(SLICE_COLOR_TYPE_ID); + expect(net.places.find((p) => p.id === 'pool:test-agent')!.typeId).toBeUndefined(); }); }); describe('serializeBlueprint — transitions', () => { - it('emits one transition entry per blueprint transition with arcs and contract metadata', () => { + it('emits the folded evaluate dispatch/complete pair with arcs and metadata', () => { const blueprint = compileTopology(simplePlan, { maxRetries: 3 }); const net = serializeBlueprint(blueprint, { runId: 'run-1', tokenIdFn: deterministicTokenId() }); - expect(net.transitions).toHaveLength(blueprint.transitions.length); - // FE-761 Slice 4: dispatch transition exists with the right shape. - const evalDispatch = net.transitions.find((t) => t.id === 'slice-1:evaluate:dispatch')!; + const evalDispatch = net.transitions.find((t) => t.id === 'evaluate:dispatch')!; expect(evalDispatch).toBeDefined(); expect(evalDispatch.lane).toBe('mechanical'); expect(evalDispatch.kind).toBe('structural'); - expect(evalDispatch.inputs).toEqual(['slice:slice-1:spec-ready', 'pool:test-agent']); - expect(evalDispatch.outputs).toEqual(['slice:slice-1:evaluate:running']); + expect(evalDispatch.inputs).toEqual(['spec-ready', 'pool:test-agent']); + expect(evalDispatch.outputs).toEqual(['evaluate:running']); - // Complete transition carries the action descriptor; outputs include - // the report-bearing intermediate and the agent pool return. - const evalComplete = net.transitions.find((t) => t.id === 'slice-1:evaluate:complete')!; + const evalComplete = net.transitions.find((t) => t.id === 'evaluate:complete')!; expect(evalComplete).toBeDefined(); expect(evalComplete.kind).toBe('mechanical'); expect(evalComplete.actor).toBe('evaluator'); - expect(evalComplete.inputs).toEqual(['slice:slice-1:evaluate:running']); - expect(evalComplete.outputs).toEqual(['pool:test-agent', 'slice:slice-1:evaluate:reported'].sort()); - }); - - it('every transition output appears as a declared place', () => { - const blueprint = compileTopology(depPlan, { maxRetries: 3 }); - const net = serializeBlueprint(blueprint, { runId: 'run-2', tokenIdFn: deterministicTokenId() }); - const placeIds = new Set(net.places.map((p) => p.id)); - for (const t of net.transitions) { - for (const out of t.outputs) { - expect(placeIds.has(out), `transition ${t.id} emits to undeclared place ${out}`).toBe(true); - } - } + expect(evalComplete.inputs).toEqual(['evaluate:running']); + expect(evalComplete.outputs).toEqual(['evaluate:reported', 'pool:test-agent'].sort()); }); }); describe('serializeBlueprint — initial marking', () => { - it('groups initial tokens by place with a fresh UUID per token', () => { - const blueprint = compileTopology(simplePlan, { maxRetries: 3 }); - const net = serializeBlueprint(blueprint, { runId: 'run-1', tokenIdFn: deterministicTokenId() }); + it('groups initial tokens into folded places, one colored token per slice', () => { + const blueprint = compileTopology(depPlan, { maxRetries: 3 }); + const net = serializeBlueprint(blueprint, { runId: 'run-2', tokenIdFn: deterministicTokenId() }); - // simplePlan seeds: - // pool:test-agent × 1, pool:code-agent × 1 (agentPoolSize defaults to slice count = 1) - // slice:slice-1:semantic-budget × 1, slice:slice-1:retry-budget × 1, - // slice:slice-1:eligible × 1 const places = net.initialMarking.map((m) => m.place).sort(); - expect(places).toEqual( - [ - 'pool:code-agent', - 'pool:test-agent', - 'slice:slice-1:eligible', - 'slice:slice-1:retry-budget', - 'slice:slice-1:semantic-budget', - ].sort(), - ); - - // Every token has an id. - for (const marking of net.initialMarking) { - for (const tok of marking.tokens) { - expect(typeof tok.id).toBe('string'); - expect(tok.id.length).toBeGreaterThan(0); - } - } - - // Semantic budget token carries reworkCount: 0; retry-budget carries retryCount: 0. - const semBudget = net.initialMarking.find((m) => m.place === 'slice:slice-1:semantic-budget')!; - expect(semBudget.tokens[0]!.reworkCount).toBe(0); - expect(semBudget.tokens[0]!.sliceId).toBe('slice-1'); - expect(semBudget.tokens[0]!.epicId).toBe('epic-1'); - - const retryBudget = net.initialMarking.find((m) => m.place === 'slice:slice-1:retry-budget')!; - expect(retryBudget.tokens[0]!.retryCount).toBe(0); - - // Pool tokens have no sliceId / epicId (shared pool). + expect(places).toEqual([ + 'eligible', + 'pool:code-agent', + 'pool:test-agent', + 'retry-budget', + 'semantic-budget', + ]); + + // eligible folds both slices' seeds → two colored tokens. + const eligible = net.initialMarking.find((m) => m.place === 'eligible')!; + expect(eligible.tokens.map((t) => t.sliceId).sort()).toEqual(['slice-a', 'slice-b']); + + // Pool tokens carry no slice color. const pool = net.initialMarking.find((m) => m.place === 'pool:test-agent')!; expect(pool.tokens[0]!.sliceId).toBeUndefined(); - expect(pool.tokens[0]!.epicId).toBeUndefined(); + }); + + it('carries budget counters on the folded budget places', () => { + const blueprint = compileTopology(simplePlan, { maxRetries: 3 }); + const net = serializeBlueprint(blueprint, { runId: 'run-1', tokenIdFn: deterministicTokenId() }); + expect(net.initialMarking.find((m) => m.place === 'semantic-budget')!.tokens[0]!.reworkCount).toBe(0); + expect(net.initialMarking.find((m) => m.place === 'retry-budget')!.tokens[0]!.retryCount).toBe(0); }); it('emits distinct token ids for every initial token', () => { @@ -167,8 +179,8 @@ describe('serializeBlueprint — initial marking', () => { }); }); -describe('serializeBlueprint — golden counts pinned per fixture', () => { - it('simplePlan: 22 places, 19 transitions, 5 places hold initial tokens', () => { +describe('serializeBlueprint — golden fold counts pinned per fixture', () => { + it('simplePlan (1 slice): fold is a relabel — 22 places, 19 transitions, 5 marked', () => { const blueprint = compileTopology(simplePlan, { maxRetries: 3 }); const net = serializeBlueprint(blueprint, { runId: 'run-1', tokenIdFn: deterministicTokenId() }); expect(net.places.length).toBe(22); @@ -176,12 +188,11 @@ describe('serializeBlueprint — golden counts pinned per fixture', () => { expect(net.initialMarking.length).toBe(5); }); - it('depPlan: 42 places, 37 transitions, 8 places hold initial tokens', () => { + it('depPlan (2 slices): lifecycle collapses — 23 places, 21 transitions, 5 marked', () => { const blueprint = compileTopology(depPlan, { maxRetries: 3 }); const net = serializeBlueprint(blueprint, { runId: 'run-2', tokenIdFn: deterministicTokenId() }); - expect(net.places.length).toBe(42); - expect(net.transitions.length).toBe(37); - // 2 pools + 2 slices × 3 per-slice seeded places (semantic-budget, retry-budget, eligible) - expect(net.initialMarking.length).toBe(8); + expect(net.places.length).toBe(23); + expect(net.transitions.length).toBe(21); + expect(net.initialMarking.length).toBe(5); }); }); diff --git a/src/orchestrator/src/petrinaut-export.ts b/src/orchestrator/src/petrinaut-export.ts index 652e1bd0..87319dd9 100644 --- a/src/orchestrator/src/petrinaut-export.ts +++ b/src/orchestrator/src/petrinaut-export.ts @@ -18,14 +18,17 @@ import { randomUUID } from 'node:crypto'; -import { enumerateCandidateOutputs } from './net-blueprint.js'; import type { NetBlueprint, TokenSeed } from './net-blueprint.js'; +import { createNetFolding, type PetrinautTokenType } from './petrinaut-fold.js'; /** * Schema version of the exported JSON. Bump on any breaking shape change so * Petrinaut loaders can refuse incompatible runs early. + * + * 0.2.0 — FE-784: per-slice subnet is color-folded (one subnet, slice + * identity on the token); adds `tokenTypes` + place `typeId`. */ -export const PETRINAUT_NET_SCHEMA_VERSION = '0.1.0'; +export const PETRINAUT_NET_SCHEMA_VERSION = '0.2.0'; /** * Per-instance Petrinaut token. Cross-team-agreed shape (2026-05-26): @@ -44,10 +47,12 @@ export type PetrinautToken = { }; export type PetrinautPlace = { - /** Internal place ID (e.g. `slice:slice-1:spec-ready`). */ + /** Folded place ID — the slice-independent role (e.g. `spec-ready`). */ id: string; /** Short visual label with the `slice::` / `epic::` prefix stripped. */ label: string; + /** Color type for places that hold slice-colored tokens (folded slice places). */ + typeId?: string; }; export type PetrinautTransition = { @@ -77,6 +82,8 @@ export type PetrinautMarking = { export type PetrinautNet = { schemaVersion: string; runId: string; + /** Token color types referenced by places via `typeId` (FE-784). */ + tokenTypes: PetrinautTokenType[]; places: PetrinautPlace[]; transitions: PetrinautTransition[]; initialMarking: PetrinautMarking[]; @@ -98,44 +105,41 @@ export type SerializeBlueprintOpts = { */ export function serializeBlueprint(blueprint: NetBlueprint, opts: SerializeBlueprintOpts): PetrinautNet { const tokenId = opts.tokenIdFn ?? randomUUID; + const folding = createNetFolding(blueprint); - const places: PetrinautPlace[] = blueprint.places.map((id) => ({ - id, - label: shortPlaceLabel(id), + const places: PetrinautPlace[] = folding.foldedPlaces().map((p) => ({ + id: p.id, + label: shortPlaceLabel(p.id), + ...(p.typeId !== undefined ? { typeId: p.typeId } : {}), })); - const transitions: PetrinautTransition[] = blueprint.transitions.map((t) => { - const outs = enumerateCandidateOutputs(t); - return { - id: t.id, - label: t.id, - kind: t.contract.kind, - ...(t.contract.lane !== undefined ? { lane: t.contract.lane } : {}), - ...(t.contract.actor !== undefined ? { actor: t.contract.actor } : {}), - ...(t.contract.guard !== undefined ? { guard: t.contract.guard } : {}), - inputs: [...t.inputs], - outputs: [...outs].sort(), - }; - }); - - // Group initial tokens by place, preserving declaration order within each place. - const byPlace = new Map(); - for (const { place, token } of blueprint.initialTokens) { - const list = byPlace.get(place) ?? []; - list.push(token); - byPlace.set(place, list); - } + const transitions: PetrinautTransition[] = folding.foldedTransitions().map((t) => ({ + id: t.id, + label: t.id, + kind: t.contract.kind, + ...(t.contract.lane !== undefined ? { lane: t.contract.lane } : {}), + ...(t.contract.actor !== undefined ? { actor: t.contract.actor } : {}), + ...(t.contract.guard !== undefined ? { guard: t.contract.guard } : {}), + inputs: [...t.inputs], + outputs: [...t.outputs], + })); + // Initial marking — fold tokens into folded places (each token keeps its + // slice color), then sort by place and stamp a fresh UUID per token. + const byPlace = folding.foldedMarking( + blueprint.initialTokens.map(({ place, token }) => [place, [token]] as const), + ); const initialMarking: PetrinautMarking[] = Array.from(byPlace.entries()) .sort(([a], [b]) => a.localeCompare(b)) - .map(([place, tokens]) => ({ + .map(([place, seeds]) => ({ place, - tokens: tokens.map((seed) => seedToToken(seed, tokenId())), + tokens: seeds.map((seed) => seedToToken(seed, tokenId())), })); return { schemaVersion: PETRINAUT_NET_SCHEMA_VERSION, runId: opts.runId, + tokenTypes: [...folding.tokenTypes()], places, transitions, initialMarking, diff --git a/src/orchestrator/src/petrinaut-fold.test.ts b/src/orchestrator/src/petrinaut-fold.test.ts new file mode 100644 index 00000000..2279f48b --- /dev/null +++ b/src/orchestrator/src/petrinaut-fold.test.ts @@ -0,0 +1,175 @@ +import { describe, expect, it } from 'vitest'; + +import type { NetBlueprint, TransitionSkeleton } from './net-blueprint.js'; +import { compileTopology } from './net-compiler.js'; +import { createNetFolding, SLICE_COLOR_TYPE_ID } from './petrinaut-fold.js'; +import type { Plan } from './types.js'; + +// A compact two-slice blueprint exercising every fold rule without depending on +// the compiler: uniform lifecycle transitions (evaluate:dispatch), a divergent +// dependency gate (slice-ready, slice-b carries a dep-signal input slice-a +// lacks), a per-edge dep-signal place, and unfolded epic/pool nodes. +function tx(id: string, inputs: string[], outputs: string[]): TransitionSkeleton { + return { + id, + inputs, + contract: { kind: 'structural', lane: 'mechanical' }, + handler: { kind: 'passthrough', outputs: outputs.map((place) => ({ place, sliceId: '', epicId: '' })) }, + }; +} + +const blueprint: NetBlueprint = { + places: [ + 'pool:test-agent', + 'slice:slice-a:eligible', + 'slice:slice-a:spec-ready', + 'slice:slice-a:evaluate:running', + 'slice:slice-b:eligible', + 'slice:slice-b:spec-ready', + 'slice:slice-b:evaluate:running', + 'slice:slice-a:dep-signal:slice-b', + 'epic:epic-1:done', + ], + transitions: [ + tx('slice-ready:slice-a', ['slice:slice-a:eligible'], ['slice:slice-a:spec-ready']), + tx( + 'slice-ready:slice-b', + ['slice:slice-b:eligible', 'slice:slice-a:dep-signal:slice-b'], + ['slice:slice-b:spec-ready'], + ), + tx( + 'slice-a:evaluate:dispatch', + ['slice:slice-a:spec-ready', 'pool:test-agent'], + ['slice:slice-a:evaluate:running'], + ), + tx( + 'slice-b:evaluate:dispatch', + ['slice:slice-b:spec-ready', 'pool:test-agent'], + ['slice:slice-b:evaluate:running'], + ), + tx( + 'epic-complete:epic-1', + ['slice:slice-a:spec-ready', 'slice:slice-b:spec-ready'], + ['epic:epic-1:done'], + ), + ], + initialTokens: [ + { place: 'slice:slice-a:eligible', token: { sliceId: 'slice-a', epicId: 'epic-1' } }, + { place: 'slice:slice-b:eligible', token: { sliceId: 'slice-b', epicId: 'epic-1' } }, + { place: 'pool:test-agent', token: { sliceId: '', epicId: '' } }, + ], +}; + +describe('createNetFolding — foldedPlaces', () => { + const folding = createNetFolding(blueprint); + + it('strips slice:: and dedupes the lifecycle places to one each', () => { + const ids = folding.foldedPlaces().map((p) => p.id); + expect(ids.filter((id) => id === 'spec-ready')).toHaveLength(1); + expect(ids.filter((id) => id === 'evaluate:running')).toHaveLength(1); + expect(ids.every((id) => !id.startsWith('slice:'))).toBe(true); + }); + + it('keeps per-edge dep-signal and epic/pool places unchanged', () => { + const ids = folding.foldedPlaces().map((p) => p.id); + expect(ids).toContain('dep-signal:slice-b'); + expect(ids).toContain('epic:epic-1:done'); + expect(ids).toContain('pool:test-agent'); + }); + + it('tags folded slice places with the color type and leaves pool/epic untyped', () => { + const byId = new Map(folding.foldedPlaces().map((p) => [p.id, p])); + expect(byId.get('spec-ready')!.typeId).toBe(SLICE_COLOR_TYPE_ID); + expect(byId.get('pool:test-agent')!.typeId).toBeUndefined(); + expect(byId.get('epic:epic-1:done')!.typeId).toBeUndefined(); + }); +}); + +describe('createNetFolding — foldedTransitions / foldTransition', () => { + const folding = createNetFolding(blueprint); + + it('collapses uniform per-slice transitions to one folded node', () => { + const ids = folding.foldedTransitions().map((t) => t.id); + expect(ids.filter((id) => id === 'evaluate:dispatch')).toHaveLength(1); + const dispatch = folding.foldedTransitions().find((t) => t.id === 'evaluate:dispatch')!; + expect(dispatch.inputs).toEqual(['spec-ready', 'pool:test-agent']); + expect(dispatch.outputs).toEqual(['evaluate:running']); + expect(folding.foldTransition('slice-a:evaluate:dispatch')).toBe('evaluate:dispatch'); + expect(folding.foldTransition('slice-b:evaluate:dispatch')).toBe('evaluate:dispatch'); + }); + + it('keeps divergent dependency gates at their concrete ids', () => { + const ids = new Set(folding.foldedTransitions().map((t) => t.id)); + expect(ids.has('slice-ready:slice-a')).toBe(true); + expect(ids.has('slice-ready:slice-b')).toBe(true); + expect(ids.has('slice-ready')).toBe(false); + expect(folding.foldTransition('slice-ready:slice-a')).toBe('slice-ready:slice-a'); + }); + + it('leaves epic transitions (no slice-id segment) unchanged', () => { + expect(folding.foldTransition('epic-complete:epic-1')).toBe('epic-complete:epic-1'); + }); +}); + +describe('createNetFolding — foldedMarking', () => { + const folding = createNetFolding(blueprint); + + it('merges token lists for places that fold together and preserves empty keys', () => { + const folded = folding.foldedMarking([ + ['slice:slice-a:eligible', ['a']], + ['slice:slice-b:eligible', ['b']], + ['pool:test-agent', []], + ]); + expect(folded.get('eligible')).toEqual(['a', 'b']); + expect(folded.get('pool:test-agent')).toEqual([]); + }); +}); + +describe('createNetFolding — tokenTypes', () => { + it('declares the SliceColor type when slice places are present', () => { + const types = createNetFolding(blueprint).tokenTypes(); + expect(types.map((t) => t.id)).toEqual([SLICE_COLOR_TYPE_ID]); + }); +}); + +describe('createNetFolding — divergence is bounded to dependency gates', () => { + // Card #3: the only transitions allowed to keep a per-slice (concrete) id are + // the dependency gates whose arcs genuinely differ per slice. Anything else + // staying slice-scoped means a uniform lifecycle transition silently failed + // to fold — the graph re-expands while reading as "fold worked". + const depPlan: Plan = { + epics: [{ id: 'epic-1', summary: 'E', depends_on: [], verification: [] }], + slices: [ + { + id: 'slice-a', + epic_id: 'epic-1', + definition: 'A', + depends_on: [], + verification: [{ kind: 'unit-test', target: 'ta' }], + }, + { + id: 'slice-b', + epic_id: 'epic-1', + definition: 'B', + depends_on: ['slice-a'], + verification: [{ kind: 'unit-test', target: 'tb' }], + }, + ], + }; + + it('keeps exactly the dependency gates (slice-ready, return-done) concrete and folds everything else', () => { + const compiled = compileTopology(depPlan, { maxRetries: 3 }); + const folding = createNetFolding(compiled); + const sliceIds = ['slice-a', 'slice-b']; + + // A folded transition is "still slice-scoped" iff its id carries a slice-id segment. + const stillSliceScoped = folding + .foldedTransitions() + .map((t) => t.id) + .filter((id) => id.split(':').some((seg) => sliceIds.includes(seg))); + + expect(new Set(stillSliceScoped)).toEqual( + new Set(['slice-ready:slice-a', 'slice-ready:slice-b', 'slice-a:return-done', 'slice-b:return-done']), + ); + }); +}); diff --git a/src/orchestrator/src/petrinaut-fold.ts b/src/orchestrator/src/petrinaut-fold.ts new file mode 100644 index 00000000..a9b1b917 --- /dev/null +++ b/src/orchestrator/src/petrinaut-fold.ts @@ -0,0 +1,234 @@ +// --------------------------------------------------------------------------- +// FE-784 — Color-fold of a compiled NetBlueprint for the Petrinaut projection. +// +// The compiled net emits one concrete subnet per slice (`slice::*` places, +// `:*` / `slice-ready:` transitions). Petrinaut's canvas is flat — no +// hierarchy, subnets, or grouping — so the only way to keep the imported net +// legible at scale is to collapse the N structurally-identical slice subnets +// into ONE, carrying slice identity on the token color instead of in the node +// id. +// +// `NetFolding` owns the entire concrete→folded mapping of one blueprint: the +// folded place set, the folded transition set, the per-place marking fold, and +// the color-type classification. Both consumers — the static `net.json` export +// (`serializeBlueprint`) and the live event adapter (`createPetrinautEventStream`) +// — go through one folding so the static net and the live event stream fold +// identically. The id-rule primitives below are private implementation detail. +// +// Fidelity: this is a projection only. The runtime net (`petri-net.ts` / +// `net-compiler.ts`) is untouched; it still fires concrete per-slice +// transitions. The adapter maps those firings onto the folded net. +// --------------------------------------------------------------------------- + +import { enumerateCandidateOutputs } from './net-blueprint.js'; +import type { NetBlueprint, TransitionSkeleton } from './net-blueprint.js'; +import type { TransitionContract } from './petri-net.js'; + +// --------------------------------------------------------------------------- +// Token color type — the slice identity that folding pushes onto the token. +// Emitted in net.json (`tokenTypes`); folded slice places reference it via +// `typeId`. SDCPN export stays count-fold (uncolored) until Petrinaut +// supports discrete string token dimensions (H-6518/H-6519). +// --------------------------------------------------------------------------- + +export type PetrinautTokenType = { + id: string; + name: string; + dimensions: { name: string; kind: 'discrete' | 'number' }[]; +}; + +export const SLICE_COLOR_TYPE_ID = 'slice-color'; + +export const SLICE_COLOR_TYPE: PetrinautTokenType = { + id: SLICE_COLOR_TYPE_ID, + name: 'SliceColor', + dimensions: [ + { name: 'sliceId', kind: 'discrete' }, + { name: 'epicId', kind: 'discrete' }, + { name: 'retryCount', kind: 'number' }, + { name: 'reworkCount', kind: 'number' }, + ], +}; + +// --------------------------------------------------------------------------- +// Folded node value shapes (public; the id maps stay private to the object). +// --------------------------------------------------------------------------- + +/** A place in the folded projection. `id` is the slice-independent role. */ +export type FoldedPlace = { + id: string; + /** Color type id when this folded place holds slice-colored tokens. */ + typeId?: string; +}; + +/** A transition in the folded projection. Arcs are already folded. */ +export type FoldedTransition = { + /** Exported id: shared folded id for uniform groups, concrete id for divergent members. */ + id: string; + inputs: readonly string[]; + outputs: readonly string[]; + contract: TransitionContract; +}; + +/** + * The color-fold of one compiled NetBlueprint. Built once via + * `createNetFolding`; immutable thereafter and safe to share between the + * static export and the live event stream. Callers never touch the underlying + * id maps — they only ask the folding to fold things. + */ +export type NetFolding = { + /** Folded places, deduped, in first-occurrence order. Slice places carry `typeId`. */ + foldedPlaces(): readonly FoldedPlace[]; + /** Folded transitions, deduped to their exported id, in first-occurrence order. */ + foldedTransitions(): readonly FoldedTransition[]; + /** Exported id for one concrete transition id (folded, or concrete when divergent). */ + foldTransition(transitionId: string): string; + /** + * Fold a sequence of (concrete place, tokens) entries into a map keyed by + * folded place, merging token lists for places that fold together and + * preserving empty-list keys. Pure; does not mutate inputs. + */ + foldedMarking(entries: Iterable): Map; + /** Color token types referenced by `foldedPlaces()` — `[SLICE_COLOR_TYPE]` or `[]`. */ + tokenTypes(): readonly PetrinautTokenType[]; +}; + +/** + * Build the folding for a compiled blueprint. Pure and deterministic: computes + * the slice-id set and transition fold map once, O(places + transitions). The + * returned folding is only meaningful for ids originating from this blueprint. + */ +export function createNetFolding(blueprint: NetBlueprint): NetFolding { + const sliceIds = collectSliceIds(blueprint.places); + const transitionFoldMap = buildTransitionFoldMap(blueprint.transitions, sliceIds); + + // Folded places — dedupe by folded id; a folded slice place carries the + // slice color type. + const placeById = new Map(); + for (const id of blueprint.places) { + const folded = foldPlaceId(id); + if (placeById.has(folded)) continue; + placeById.set(folded, { + id: folded, + ...(id.startsWith('slice:') ? { typeId: SLICE_COLOR_TYPE_ID } : {}), + }); + } + const places = [...placeById.values()]; + + // Folded transitions — one entry per exported id; uniform members collapse, + // divergent members keep their concrete id. + const transitionById = new Map(); + for (const t of blueprint.transitions) { + const exportedId = transitionFoldMap.get(t.id)!; + if (transitionById.has(exportedId)) continue; + transitionById.set(exportedId, { + id: exportedId, + inputs: [...new Set(t.inputs.map(foldPlaceId))], + outputs: [...new Set([...enumerateCandidateOutputs(t)].map(foldPlaceId))].sort(), + contract: t.contract, + }); + } + const transitions = [...transitionById.values()]; + + const hasSliceColor = places.some((p) => p.typeId === SLICE_COLOR_TYPE_ID); + + return { + foldedPlaces: () => places, + foldedTransitions: () => transitions, + foldTransition: (transitionId) => + transitionFoldMap.get(transitionId) ?? foldTransitionId(transitionId, sliceIds), + foldedMarking(entries: Iterable): Map { + const out = new Map(); + for (const [place, tokens] of entries) { + const folded = foldPlaceId(place); + const list = out.get(folded) ?? []; + for (const t of tokens) list.push(t); + out.set(folded, list); + } + return out; + }, + tokenTypes: () => (hasSliceColor ? [SLICE_COLOR_TYPE] : []), + }; +} + +// --------------------------------------------------------------------------- +// Private id-folding primitives — the implementation of createNetFolding. +// Not exported: the only public fold surface is NetFolding, so there is no +// parallel API that could drift from the folded net. +// --------------------------------------------------------------------------- + +/** Collect the distinct slice ids from `slice::…` place ids. */ +function collectSliceIds(placeIds: Iterable): Set { + const ids = new Set(); + for (const id of placeIds) { + const m = id.match(/^slice:([^:]+):/); + if (m) ids.add(m[1]!); + } + return ids; +} + +/** + * Fold a place id to its slice-independent role by stripping the + * `slice::` prefix. Per-edge `dep-signal:` places keep the + * dependent id (they are genuinely per-edge, so they fold to a unique role). + * Epic, pool, and bare places are returned unchanged. + */ +function foldPlaceId(placeId: string): string { + const m = placeId.match(/^slice:[^:]+:(.+)$/); + return m ? m[1]! : placeId; +} + +/** + * Fold a transition id to its slice-independent role by removing the owning + * slice-id segment wherever it appears (sid-prefixed transitions like + * `slice-1:evaluate:dispatch`, and the sid-suffixed readiness gate + * `slice-ready:slice-1`). A transition id references only its own slice, so + * removing any slice-id segment is safe. Epic transitions carry no slice-id + * segment and are returned unchanged. + */ +function foldTransitionId(transitionId: string, sliceIds: ReadonlySet): string { + return transitionId + .split(':') + .filter((seg) => !sliceIds.has(seg)) + .join(':'); +} + +/** + * The folded shape that decides fold identity: a transition's folded arcs plus + * its contract metadata. Members of a folded group sharing this signature + * collapse to one node; a group whose members differ (e.g. dep-gated + * `slice-ready`, dep-signalling `return-done`) keeps each member concrete. + * `guard` is excluded deliberately — it is role-derived, never the thing that + * distinguishes two slices' copies of the same transition. + */ +function foldedShapeSignature(t: TransitionSkeleton): string { + const inputs = [...new Set(t.inputs.map(foldPlaceId))].sort(); + const outputs = [...new Set([...enumerateCandidateOutputs(t)].map(foldPlaceId))].sort(); + const c = t.contract; + return JSON.stringify({ inputs, outputs, kind: c.kind, lane: c.lane ?? null, actor: c.actor ?? null }); +} + +/** + * Map each concrete transition id to the id it is exported as. Uniform folded + * groups map every member to the shared folded id; divergent groups map each + * member to its own concrete id. + */ +function buildTransitionFoldMap( + transitions: readonly TransitionSkeleton[], + sliceIds: ReadonlySet, +): Map { + const groups = new Map(); + for (const t of transitions) { + const folded = foldTransitionId(t.id, sliceIds); + const list = groups.get(folded) ?? []; + list.push({ id: t.id, sig: foldedShapeSignature(t) }); + groups.set(folded, list); + } + + const map = new Map(); + for (const [folded, members] of groups) { + const uniform = members.every((m) => m.sig === members[0]!.sig); + for (const m of members) map.set(m.id, uniform ? folded : m.id); + } + return map; +} diff --git a/src/orchestrator/src/petrinaut-sdcpn.test.ts b/src/orchestrator/src/petrinaut-sdcpn.test.ts index 955dcff3..4f5fcafe 100644 --- a/src/orchestrator/src/petrinaut-sdcpn.test.ts +++ b/src/orchestrator/src/petrinaut-sdcpn.test.ts @@ -91,6 +91,26 @@ const simplePlan: Plan = { ], }; +const depPlan: Plan = { + epics: [{ id: 'epic-1', summary: 'E', depends_on: [], verification: [] }], + slices: [ + { + id: 'slice-a', + epic_id: 'epic-1', + definition: 'A', + depends_on: [], + verification: [{ kind: 'unit-test', target: 'ta' }], + }, + { + id: 'slice-b', + epic_id: 'epic-1', + definition: 'B', + depends_on: ['slice-a'], + verification: [{ kind: 'unit-test', target: 'tb' }], + }, + ], +}; + /** Build a real PetrinautNet from a plan (the actual `net.json` shape). */ function realNet(plan: Plan): PetrinautNet { return serializeBlueprint(compileTopology(plan, { maxRetries: 3 }), { runId: 'run-1' }); @@ -101,7 +121,7 @@ describe('toSdcpnFile — envelope', () => { const file = toSdcpnFile(realNet(simplePlan), {}); expect(file.version).toBe(SDCPN_FILE_FORMAT_VERSION); expect(file.meta.generator).toBe('brunch'); - expect(file.meta.generatorVersion).toBe('0.1.0'); + expect(file.meta.generatorVersion).toBe('0.2.0'); }); it('defaults the title to the runId and honours an override', () => { @@ -109,7 +129,7 @@ describe('toSdcpnFile — envelope', () => { expect(toSdcpnFile(realNet(simplePlan), { title: 'My Net' }).title).toBe('My Net'); }); - it('includes all SDCPN collections (empty for an uncoloured net)', () => { + it('includes all SDCPN collections (empty for an uncolored net)', () => { const file = toSdcpnFile(realNet(simplePlan), {}); expect(file.types).toEqual([]); expect(file.differentialEquations).toEqual([]); @@ -119,7 +139,7 @@ describe('toSdcpnFile — envelope', () => { }); describe('toSdcpnFile — places', () => { - it('preserves every place id as an uncoloured place', () => { + it('preserves every place id as an uncolored place', () => { const net = realNet(simplePlan); const file = toSdcpnFile(net, {}); expect(file.places.map((p) => p.id).sort()).toEqual(net.places.map((p) => p.id).sort()); @@ -134,6 +154,7 @@ describe('toSdcpnFile — places', () => { const net: PetrinautNet = { schemaVersion: '0.1.0', runId: 'r', + tokenTypes: [], places: [ { id: 'slice:version-flag:spec-ready', label: 'spec-ready' }, { id: 'pool:test-agent', label: 'pool:test-agent' }, @@ -150,6 +171,7 @@ describe('toSdcpnFile — places', () => { const net: PetrinautNet = { schemaVersion: '0.1.0', runId: 'r', + tokenTypes: [], places: [ { id: 'slice-1:done', label: 'done' }, { id: 'slice-2:done', label: 'done' }, @@ -168,6 +190,7 @@ describe('toSdcpnFile — transitions', () => { const net: PetrinautNet = { schemaVersion: '0.1.0', runId: 'r', + tokenTypes: [], places: [ { id: 'a', label: 'a' }, { id: 'b', label: 'b' }, @@ -193,6 +216,7 @@ describe('toSdcpnFile — initial marking', () => { const net: PetrinautNet = { schemaVersion: '0.1.0', runId: 'r', + tokenTypes: [], places: [ { id: 'pool:test-agent', label: 'pool:test-agent' }, { id: 'slice:s:eligible', label: 'eligible' }, @@ -215,6 +239,7 @@ describe('toSdcpnFile — initial marking', () => { const net: PetrinautNet = { schemaVersion: '0.1.0', runId: 'r', + tokenTypes: [], places: [{ id: 'a', label: 'a' }], transitions: [], initialMarking: [], @@ -239,3 +264,34 @@ describe('toSdcpnFile — round-trips through the Petrinaut loader', () => { } }); }); + +describe('toSdcpnFile — folded multi-slice net (FE-784)', () => { + // The color fold's original motivation: clean SDCPN names. Before folding, + // every slice produced a `slice:slice-N:spec-ready` place that PascalCased to + // the same base, so the name allocator appended collision counters + // (SliceSliceSpecReady, SliceSliceSpecReady2, …). After folding, the slice + // lifecycle collapses to one copy, so no allocator suffix is needed. + + it('produces collision-free PascalCase names with no allocator digit suffixes', () => { + const file = toSdcpnFile(realNet(depPlan), {}); + // pascalCaseLetters strips digits from the source, so any digit in a final + // name can only be an allocator collision counter — there should be none. + for (const p of file.places) { + expect(p.name, `place ${p.id} got a collision-suffixed name ${p.name}`).not.toMatch(/\d/); + } + expect(new Set(file.places.map((p) => p.name)).size).toBe(file.places.length); + }); + + it('collapses the slice lifecycle: one shared place, far fewer than an unfolded 2-slice net', () => { + const single = toSdcpnFile(realNet(simplePlan), {}).places.length; + const file = toSdcpnFile(realNet(depPlan), {}); + // Unfolded, two slices would roughly double the single-slice lifecycle. + // Folded, the shared lifecycle appears once, so dep stays well under 2×. + expect(file.places.length).toBeLessThan(single * 2); + expect(file.places.filter((p) => p.id === 'spec-ready')).toHaveLength(1); + }); + + it('still satisfies the Petrinaut loader schema', () => { + expect(sdcpnFileSchema.safeParse(toSdcpnFile(realNet(depPlan), {})).success).toBe(true); + }); +}); diff --git a/src/orchestrator/src/petrinaut-sdcpn.ts b/src/orchestrator/src/petrinaut-sdcpn.ts index a4cc7537..ec4ec905 100644 --- a/src/orchestrator/src/petrinaut-sdcpn.ts +++ b/src/orchestrator/src/petrinaut-sdcpn.ts @@ -12,12 +12,12 @@ // validate it against a mirror of Petrinaut's loader schema. // // Fidelity notes (v1): -// - All places are uncoloured (`colorId: null`); tokens carry no attributes, +// - All places are uncolored (`colorId: null`); tokens carry no attributes, // so the marking collapses to per-place counts. // - Guards live only as human-readable strings in net.json and cannot be -// expressed structurally without inhibitor arcs / coloured tokens, so every +// expressed structurally without inhibitor arcs / colored tokens, so every // transition gets a permissive `predicate` lambda (always enabled) and an -// empty kernel (uncoloured output places are auto-populated by Petrinaut). +// empty kernel (uncolored output places are auto-populated by Petrinaut). // - Initial marking maps to a single `per_place` scenario keyed by place ID // (Petrinaut's per_place content is keyed by ID, not name). // --------------------------------------------------------------------------- @@ -29,7 +29,7 @@ export const SDCPN_FILE_FORMAT_VERSION = 1; /** Predicate lambda that always enables the transition (presence-gated firing). */ const ALWAYS_ENABLED_LAMBDA = 'export default Lambda(() => true)'; -/** Empty kernel — every output place is uncoloured, so none are listed. */ +/** Empty kernel — every output place is uncolored, so none are listed. */ const EMPTY_KERNEL = 'export default TransitionKernel(() => ({}))'; export type SdcpnPlace = {