Skip to content

SY-4068: Strongly Typed Schematic Node and Edge Configs#2474

Open
emilbon99 wants to merge 108 commits into
rcfrom
sy-4068-strongly-type-schematic-node-and-edge-props
Open

SY-4068: Strongly Typed Schematic Node and Edge Configs#2474
emilbon99 wants to merge 108 commits into
rcfrom
sy-4068-strongly-type-schematic-node-and-edge-props

Conversation

@emilbon99

@emilbon99 emilbon99 commented Jun 10, 2026

Copy link
Copy Markdown
Contributor

Issue Pull Request

Linear Issue

SY-4068

Description

Replaces the schematic's opaque per-element config storage with a fully typed model, end to end. The schema now declares every symbol and edge configuration: a NodeConfig union over all 100 node variants (44 static labeled symbols, 31 telemetry-actuated toggles, and 7 local dummy toggles share three payload structs; the remaining 18 symbols get bespoke structs), an EdgeConfig union over the 7 edge styles, and an ElementConfig union composed from both via union extends. The configs map flips from map<string, record> to map<string, ElementConfig>, so configs are validated on every write path instead of trusted: the Go reducer decodes merged action payloads through the union (normalizing the camelCase keys the Console previously stored verbatim, with telem props and custom-symbol state overrides exempt since their keys are semantic), and the TS reducer parses through elementConfigZ for parity.

Pluto's hand-written schemas become re-exports of the generated ones: symbol and edge factories resolve their config schemas from the generated schema maps, every singleton config file re-exports its generated variant schema, and the registries' variant enums come from the generated types with satisfies guards against schema/registry drift. Telemetry source/sink references are modeled as single-variant unions on value_type with role-pinned variants and opaque, factory-validated props, preserving the existing per-value-type compile-time narrowing. Border radius moves into a new border module following the color pattern: a canonical generated struct with a crude-accepting Go UnmarshalJSON and a hand-written crude zod union in x.

Optional config fields whose absence is meaningful (enums, colors, dimensions, telem specs, nested configs) are hard-optional so absence round-trips faithfully instead of collapsing to zero values that downstream optional schemas reject; only fields where zero and absent coincide stay soft-optional. Map keys stay verbatim on the wire via the new preserve_keys domain while value fields convert like any typed payload.

Storage migrates losslessly: the migration chain re-anchors on the v56 snapshot, the v55 blob lift moves into the v56 mirror package, and a new v56_typed_element_configs step normalizes stored camelCase config keys and decodes them into the union, dropping entries that match no variant. Without the migration, pre-flip datastores would silently shed multi-word config fields on read.

Stacked on #2416, which gained the generator capabilities this branch exercises: union composition via extends, union pb translators and orc codec support, multi-word discriminator camelization, nil-variant null marshaling, the preserve_keys caseconv mode, and skipping auto-copy for fields whose union-ness changed.

Basic Readiness

  • I have performed a self-review of my code.
  • I have added relevant, automated tests to cover the changes.
  • I have updated documentation to reflect the changes.

Greptile Summary

This PR replaces the schematic's opaque map<string, record> config storage with a fully typed map<string, ElementConfig> union covering all 100+ node variants and 7 edge styles, validated end-to-end on every read and write path. A two-step migration chain (v55→v56 blob lift, then v56→current key normalization + telem arg extraction + union decode) converts existing stored camelCase configs losslessly, with the same logic mirrored in the TypeScript console migration.

  • Migration pipeline: v56/migrate.go does the structural reshape (node/edge lift, stump stripping); migrate.go does the new v56→current step that normalizes camelCase keys, extracts legacy telem pipeline specs into semantic args (channel, rolling_average, etc.), and decodes through the typed union—dropping only entries that fail to decode.
  • Action handlers (SetNodePayload, SetConfigPayload) now run every write through normalizeConfigKeys + decodeElementConfig, rejecting configs that match no known variant; telem arg extraction is intentionally omitted from the live write path since new Console clients use the semantic-arg schema directly.
  • Border radius gains a new module with a custom UnmarshalJSON that accepts bare numbers, {x,y} pairs, per-corner numbers, and the canonical per-corner pair form for backward-compatible deserialization of legacy data.

Confidence Score: 5/5

Safe to merge; the migration chain is correctly ordered and losslessly converts stored camelCase configs to the typed union, and the live write path rejects invalid configs rather than silently storing them.

All migration steps are well-tested with snapshot fixtures, unit tests for telem extraction, and an end-to-end schema validation test on the Console side. The three concerns flagged in earlier review threads are the only known issues, all of which affect theoretical paths or documentation rather than data correctness. No new correctness issues were found during this review.

core/pkg/service/schematic/actions.go — the edge source-color inheritance guard has a logic inversion noted in a previous thread. core/pkg/service/schematic/migrate.go — the doc comment on the drop condition is broader than stated, also from a previous thread.

Important Files Changed

Filename Overview
core/pkg/service/schematic/convert.go New file implementing camelCase→snake_case key normalization, opaque field handling for telem pipeline props, and legacy telem arg extraction. Logic is correct and mirrors the TS side faithfully.
core/pkg/service/schematic/migrate.go New v56→current migration step: normalize keys, extract telem args, decode through typed union. Previously-flagged doc comment understates the drop condition (any decode failure, not just unknown variants). Core logic is sound.
core/pkg/service/schematic/actions.go Write handlers now validate configs through the typed union. Edge color inheritance has a theoretical logic inversion flagged in a previous review thread. Source-color path works correctly in all current callers since marshal of opaque maps never fails.
core/pkg/service/schematic/migrations/v56/migrate.go v55→v56 migration moved here from the top-level package; reshapes nodes, edges (stump stripping), and props. Logic is unchanged and correct.
console/src/schematic/types/v6.ts Adds extractTelemArgs mirroring the Go logic and applies caseconv.camelToSnake to variant keys during props-to-configs migration. Edge and node paths are symmetric with the server side.
x/go/border/border.go New border package with custom UnmarshalJSON accepting four legacy radius shapes; well-tested with round-trip test. Corner key aliasing (camelCase/snake_case) enables lossless migration of pre-typed configs.
core/pkg/service/schematic/service.go Inserts the new v56_typed_element_configs migration step between v55_lift_typed_schematic and the current schema; dependency chain is correctly ordered.
console/src/schematic/services/import.spec.ts New test cases cover camelCase→snake_case variant normalization, telem channel extraction, rolling average, zero-channel sentinel, authority extraction, and end-to-end schema validation for legacy imports.
core/pkg/service/schematic/actions_test.go Updated tests now use typed configs and helper factories; adds rejection test for configs that match no variant. Covers all previously tested scenarios plus the new validation contract.
core/pkg/service/schematic/migrations/migrate_test.go Updated to run the full two-step migration chain; adds tests for telem arg extraction and camelCase key normalization through the typed union. Existing edge/stump tests adapted for typed config assertions.
x/ts/src/border/border.ts New TS border module exporting radiusZ with four accepted formats as a zod union, matching the Go custom unmarshaler.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[v55 Schematic\nopaque blob] -->|v55_lift_typed_schematic| B[v56 Schematic\nConfigs: map string EncodedJSON]
    B -->|v56_typed_element_configs| C[Current Schematic\nConfigs: map string ElementConfig]

    subgraph v56_step["v56→current: migrateConfigEntry"]
        D[normalizeConfigKeys\ncamelCase→snake_case keys\nvariant value normalized] --> E[extractTelemArgs\nlift telem pipeline specs\ninto semantic args]
        E --> F[decodeElementConfig\nvalidate through\nElementConfig union]
        F -->|decode fails| G[drop entry silently]
        F -->|decode succeeds| H[typed ElementConfig]
    end

    B --> D

    subgraph live_write["Live Write Path: SetConfigPayload.Handle"]
        I[p.Config EncodedJSON] --> J[normalizeConfigKeys]
        J -->|existing entry| K[elementConfigFields\nmarshal→unmarshal typed→map]
        K --> L[maps.Copy merge]
        L --> M[decodeElementConfig]
        J -->|new entry + edge| N[source color inheritance\nfrom srcCfg]
        N --> M
        M -->|error| O[return error to caller]
        M -->|ok| P[store typed ElementConfig]
    end
Loading

Reviews (2): Last reviewed commit: "Merge branch 'sy-3999-oracle-support-for..." | Re-trigger Greptile

emilbon99 and others added 30 commits June 3, 2026 11:37
Internally-tagged discriminated unions in Oracle (`Scale union on type { ... }`)
generated across TypeScript (z.discriminatedUnion), Go (sealed-interface variants +
wrapper + two-pass codec), Python (Annotated discriminated unions), C++ (std::variant),
and protobuf (oneof). Includes a representative NI example schema.

Squashed from the original 15-commit branch; stacked on array defaults (#2432).
…for-struct-unions

# Conflicts:
#	oracle/analyzer/analyzer.go
#	oracle/analyzer/analyzer_test.go
#	oracle/formatter/formatter.go
#	oracle/formatter/formatter_test.go
#	oracle/parser/OracleParser.g4
#	oracle/parser/OracleParser.interp
#	oracle/parser/oracle_parser.go
#	oracle/parser/oracleparser_base_listener.go
#	oracle/parser/oracleparser_listener.go
#	oracle/plugin/cpp/types/types.go
#	oracle/plugin/cpp/types/types_test.go
#	oracle/plugin/py/types/types.go
#	oracle/plugin/py/types/types_test.go
#	oracle/plugin/ts/types/types.go
#	oracle/plugin/ts/types/validation_test.go
#	oracle/resolution/domain.go
#	oracle/resolution/type.go
#	oracle/resolution/types.go
String-valued Oracle enums generate as a Python Literal alias with module-level
constants, not an Enum class, so emitting an enum default as Units.volts is
member access on a typing special form and fails mypy. Reference the variant's
string value directly for string enums (mirroring the TypeScript plugin), while
integer enums keep Enum.member access since they generate as IntEnum classes.
…ributes)

Union variants now render documentation consistently with regular structs: the
TypeScript variant schema's JSDoc comment sits directly above the export (no
stray blank line), and the Python variant model emits a full Google-style
docstring with an Attributes section for its flattened fields instead of only a
one-line summary.
The union-level doc was misplaced or dropped: TypeScript emitted it above the
first variant schema (reading as that variant's doc) with a stray blank line,
C++ emitted it above the std::variant alias with a stray blank line, and Python
dropped it entirely. It now attaches directly above each language's union type
-- the z.discriminatedUnion export, the std::variant using-alias, and (as a
line comment, since aliases cannot carry a docstring) the Annotated alias.
Union variant type names doubled the union's category acronym (AIChannel's
ai_voltage variant became AIChannelAiVoltage). Added casing.VariantTypeName,
shared across the TS, Python, Go, and C++ plugins, which factors out the
union's leading acronym when the variant repeats it -- AIChannelAiVoltage
becomes AIVoltageChannel, CIChannel + ci_two_edge_sep becomes
CITwoEdgeSepChannel. Unions without an acronym prefix keep the union-name
prefix (Scale + linear stays ScaleLinear) so variant types never collide with a
field struct that shares the variant's bare name. Known initialisms (AI, CI,
AO, RTD, IEPE, RMS, ...) stay fully upper-cased via whole-segment matching.
Union variants previously flattened the union's shared base and the variant
payload into a standalone struct, duplicating every shared field across all
variants and discarding the type relationships the schema expresses. Each
variant now composes those structs through its language's existing
struct-extends mechanism, declaring only the discriminator:

  - Python: class AIVoltageChannel(BaseAIChannel, AIVoltageFields)
  - TypeScript: baseAIChannelZ.extend(aiVoltageFieldsZ.shape).extend({ type })
  - C++: struct AIVoltageChannel : BaseAIChannel, AIVoltageFields, with the
    codec routed through the existing HasExtends parse/to_json branch
  - Go already embedded the base and payload

The serialized JSON is unchanged (all three flatten inherited fields on the
wire), so this is wire-compatible; verified against real pydantic and zod that
a composed variant round-trips through the discriminated union. Field docs now
live once on the base/payload classes instead of being copied onto every
variant.
Use set.Set for the acronym membership set instead of map[string]struct{},
and maps.Copy for the union domain copy loop.
- validateUnion now rejects a variant value equal to the discriminator field
  name (would clash with the generated discriminator type) and a union base
  struct that declares a field named "variant" (reserved for the proto oneof).
- The C++ JSON codec leaves the parse parameter unnamed for field-less structs
  so they no longer trip an unused-parameter warning.
- The TypeScript enum values const no longer doubles a trailing S (UNITS stays
  UNITS instead of UNITSS).
The union feature is fully exercised by the oracle plugin and analyzer
tests. The NI schema and its generated output belong to SY-4299, which
will introduce ni.oracle as the feature's first real consumer. The
generated files were also stale, predating the later codegen changes on
this branch.
Defines Segment, SegmentedEdgeConfig, and the EdgeConfig discriminated
union (7 variants tagged on variant) in schemas/schematic.oracle and
regenerates TS, Go, and protobuf output. Pluto's hand-written edge
schemas now re-export the generated ones: segmentZ and the per-variant
configZ schemas come from @synnaxlabs/client, the edge registry's
variant enum comes from the generated EdgeConfigType, and a satisfies
guard keeps the registry keys in lockstep with the schema. The wire
configs field stays an opaque record until node configs are typed in
the next step.
TS zod schemas are always camelCase; the JSON codec handles wire-case
conversion. The union plugin passed the discriminator field name through
verbatim, so a union on value_type produced z.discriminatedUnion
("value_type", ...) with snake_case literal keys that never match
runtime data.
The go/pb plugin skipped union types entirely: no <Union>ToPB/FromPB
functions were generated, and union-typed struct fields fell through to
direct assignment between the Go sealed-interface form and the protobuf
oneof wrapper pointer, which does not compile. Unions now get dedicated
translators (nil variant maps to nil message, each variant converts its
payload through the payload struct's translator and wraps it in the
protoc oneof wrapper), and struct fields, optional fields, and array
elements route through them. Unions with extends are rejected with an
explicit error until base-field translation is implemented.
… translators

Oneof wrapper names now gain protoc's trailing underscore when a variant
value camelizes to a generated message method (a "string" variant
produces Sink_String_, not the nonexistent Sink_String). Record array
fields convert through shared recordsToPB/recordsFromPB helpers instead
of falling through to a direct assignment between []msgpack.EncodedJSON
and []*structpb.Struct.
Models all 100 schematic node symbol configurations in
schemas/schematic.oracle as a NodeConfig discriminated union: 44 static
labeled symbols, 31 telemetry-actuated toggles, and 7 local dummy
toggles share three payload structs, while the remaining 18 symbols get
bespoke structs. Telemetry source/sink references are modeled as
single-variant unions on value_type with role-pinned variants and
opaque, factory-validated props, preserving pluto's per-value-type
compile-time narrowing. A new border module owns the per-corner radius
canonical form: generated Go/pb types with a crude-accepting
UnmarshalJSON, and a hand-written crude zod union in x/ts, mirroring how
color handles crude inputs.

Pluto's hand-written node schemas are replaced by re-exports of the
generated ones: the symbol factories resolve their config schemas from
NODE_CONFIG_SCHEMAS, every singleton config file re-exports its
generated variant schema, and the node registry's variant enum and
union come from the generated NodeConfigType with a satisfies guard
against schema/registry drift. Custom symbols layer a TS-only
refinement that narrows stateOverrides to the symbol service's typed
states on top of the wire-opaque record. The per-category discriminated
unions are removed; the generated union is the single aggregation
point.
…strongly-type-schematic-node-and-edge-props

# Conflicts:
#	client/ts/src/schematic/types.gen.ts
A union may now extend other unions, mirroring enum extension: the
extending union's variant set is the union of its parents' variants
plus its own declared variants. The analyzer expands the membership
before validation, so code generators see a plain, fully populated
union and need no changes. Parents must share the extending union's
discriminator, inherited variants with conflicting payload types are
rejected, struct and union bases cannot be mixed in one declaration,
and composition over a union that extends base structs is rejected
until base-field flattening is needed.
…keys

preserve_case halts wire case conversion for an entire subtree, which is
wrong once a map's values are typed payloads: the keys are semantic
identifiers that must stay verbatim, but the values' field names must
still convert between the camelCase TS schemas and the snake_case wire.
caseconv.preserveKeys marks a record schema for exactly that split, and
the @ts preserve_keys field domain emits it.
The storage codec generator rejected unions wherever they appeared as a
stored value. Unions now encode through their generated internally
tagged MarshalJSON/UnmarshalJSON pair as length-prefixed JSON, keeping
the stored bytes self-describing across variant additions, and the
codec round-trip test generator builds a first-variant sample value for
union-typed fields.
@emilbon99 emilbon99 deleted the branch rc June 12, 2026 19:49
@emilbon99 emilbon99 closed this Jun 12, 2026
@emilbon99 emilbon99 reopened this Jun 12, 2026
@emilbon99 emilbon99 changed the base branch from sy-3999-oracle-support-for-struct-unions to rc June 12, 2026 19:51
…8-strongly-type-schematic-node-and-edge-props
…8-strongly-type-schematic-node-and-edge-props
Delete the 26 per-symbol and per-edge config.ts re-export shims in the
pluto schematic package. Symbol/edge modules now reference the
Oracle-generated schematic.* types directly: Config is inlined as
schematic.NodeConfig<X>/EdgeConfig<X>, and VARIANT/NAME are inlined as
string literals. The edge-level Config/configZ aggregate moves into
edge/registry.ts to mirror node/registry.ts, keeping the public
Edge.Config type intact.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant