Skip to content
Machine Learning
Skill

/form-graph-port

Port an existing form (a data-graph graph, or a bespoke RHF+zod form like model training) to the form-graph library. Use when asked to move a form's field logic, branching, persistence, or server validation onto form-graph. Encodes the method proven by the generation-form port —

BOOST
From plugin
civitai
7.3k48 skills15 agents3 commands
Install
$ npx -y skills add civitai/civitai --skill form-graph-port --agent claude-code

How it fires

How this skill gets triggered: by you, by Claude, or both.

  • Fires itselfAuto-invocation. Claude auto-loads it when your prompt matches the work.Auto-invocation is when the right skill fires by itself at the right moment, driven by a FLOW.md router and a hook, instead of you invoking it by name. It is the difference between a skill being installed and a skill actually getting used.Read the full definition →
  • You can call itInvoke it directly when you want it.
  • Slash command/form-graph-port

Context preview

The summary Claude sees to decide when to auto-load this skill.

Port an existing form (a data-graph graph, or a bespoke RHF+zod form like model training) to the form-graph library. Use when asked to move a form's field logic, branching, persistence, or server validation onto form-graph. Encodes the method proven by the generation-form port —

SKILL.md

form-graph-port.SKILL.md
name: form-graph-port
description: Port an existing form (a data-graph graph, or a bespoke RHF+zod form like model training) to the form-graph library. Use when asked to move a form's field logic, branching, persistence, or server validation onto form-graph. Encodes the method proven by the generation-form port — oracle-first differential testing, scope mapping, staged cutover.

Porting a form to form-graph

The method that took the generation form (~45 family graphs, 4 output types, 7 standalone workflows, ~12k differential cases) onto form-graph, distilled so the next port (e.g. model training) doesn't rediscover it. The worked example is `src/shared/form-graph/generation/` + `docs/form-graph-port-plan.md`; read the plan doc's phase structure before starting anything sizable.

0. Read the lib's own guidance first

`C:\work\form-graph\CLAUDE.md` carries the library's design invariants (one branch combinator, sync resolution, wire-named computedKeys, the prepack-after-every-edit rule for `link:` consumers). Don't design against an imagined API.

1. Identify the oracle, then build the harness FIRST

Nothing else starts until parity is measurable.

  • **Oracle = whatever produces today's wire payload.** For a data-graph form it's

`graph.safeParse`. For a bespoke form (training: `src/components/Training/Wizard` + `src/server/schema/training.schema.ts` + the orchestrator validation) it's the submit payload builder — capture real input→payload fixtures if there's no parse function.

  • **Differential = byte-identical wire.** `assertDifferential` pattern: port parse vs

oracle parse over generated cases, plus the **parse-fixpoint pin** (re-parse the port's own state → identical data; this is what makes whatIf/cost preview trustworthy).

  • **Bound every generated-case driver** — a fake that pages/loops must terminate on its

own (see CLAUDE.md's microtask-loop warning; a hang is unreportable in vitest).

  • Divergences found by the harness are *findings to record*, not always bugs — v1 does

have dead paths and quirks. Pin the deliberate deltas in a comment or the plan doc.

2. Structure: declare-then-dispatch

  • Discriminators are ordinary fields declared **above** the dispatch:

`.field('ecosystem', def)` then `.use(branch('ecosystem', [[keys, member], ...] as const))`. Group related keys into one pair — arm count should equal *family* count, not key count.

  • State-only discriminators (never on the wire) are computeds with `{ emit: false }` fed

to the tagged `branch(key, pick, members, { emit: false })` form.

  • Shared per-family plumbing goes in a `shared.ts` (`familyScope`, text-block factories,

`modelIdOf`-style raw-or-parsed readers — store state holds RAW inputs, so anything reading ctx must accept both shapes).

3. Storage: map the old adapter groups to scopes

Translate the legacy storage-adapter groups (see the v1 `createLocalStorageAdapter` config in `GenerationFormProvider.tsx` for the pattern) into graph/field `scope` declarations: graph-level `scope` for family buckets, `rootScope()` to opt a field out to global memory, `rootScope(workflow)` for per-workflow buckets, relative `[modelId]` appends for per-variant refinements. One persisted record per form (`persistedStorage('<key>')`).

4. Types: extract, never re-declare

`InferData` / `InferArm` / `InferLooseData` from the graph type the handlers (`EcosystemData<'X'>` pattern in `src/shared/form-graph/generation/types.ts`). Zero `as never`; a residual cast marks a provably-dead path and says so. After type-level work, measure compiler cost against main (`tsc --extendedDiagnostics`, delete `tsconfig.tsbuildinfo`, `NODE_OPTIONS=--max_old_space_size=12288` — default heap OOMs).

5. Stored-value migration (if old users' settings should survive)

Consumer-side module (`migrate-v1-storage.ts` is the template): read the old records, pick ONLY the fields worth carrying, build one address→raw-value record with `scopedAddress`, write it once iff the new key is absent. Values go in raw — the input schemas validate on first resolve, so stale garbage degrades to defaults. Never delete the old records while anything still reads them.

6. Cutover: ONE feature flag, always-on comparison

One feature flag (`availability: ['mod']` first, widened via its Flipt key) gates the whole cutover per user: it swaps the form component on the client AND serves the port's parse on the server (read from the ctx the feature-flag system already threads — coerce it, a sparse record reads `undefined`). Every server parse runs BOTH engines regardless and records the comparison — outcomes counted (`registerCounterWithLabels`), divergence logged with **diff keys only — never field values** (user content must not reach logs; pin that with a test, one sentinel per emit path). Comparison noise is fine: it dies with the old engine.

Flag off must be byte-identical. The generation port briefly used three flags (separate shadow/serve Flipt switches) and collapsed them once the parity battery made independent server/client rollback unnecessary — start with one. Deleting the old engine is a separate change after the flag is fully widened.

Keeping parity during the dual-graph window

Until the old graph is deleted, EVERY merge from main needs: `git diff HEAD...origin/main --stat -- src/shared/data-graph` — then mirror each change into the port AND add a differential shape covering the changed path. The suites only catch drift where shapes exercise it: krea2's community-checkpoint fix (2026-09-03) passed parity under BOTH the old and new fallback because no shape used an unknown model id. A mirrored change without a new shape is unverified.

Gotchas that cost real time on the generation port

  • Cross-field coherence (a selection retargeting another selection) belongs in a

RULE on the graph (`.effect({...})` — gesture-aware, fires before resolution, covers every writer), NOT in transcribed v1 UI handlers. v1 kept it in handl

Read more
Ships withcivitai

A repository of models, textual inversions, and more

Get the whole plugin

Other skills on civitai.